Skip to content

Day 2: Test-Driven Development

  • Session 1: 09:00 - 10:00: Testing Category
  • Session 2: 10:15 - 11:45: Hands-on: A bigger Case study
  • Session 3: 13:00 - 15:00: Hands-on: A bigger Case study
  • Session 4: 15:30 - 16:30: Overview, Discussion, Lesson learned

Dive Deeper into Unit Testing

Pada hari pertama, telah disediakan contoh bagaimana membuat aplikasi web berbasis Spring Boot dengan unit test dan functional test suite yang lengkap. Pada hari ini, kita akan mendalami terkait bagaimana membuat unit test suite yang memenuhi 5 aspek FIRST principle:

  • Fast: proses testing secara keseluruhan harus dilakukan dengan cepat.
  • Isolated: setiap unit test tidak boleh memengaruhi hasil unit test lain.
  • Repeatable: hasil tes konsisten meskipun dijalankan berkali-kali pada kondisi yang sama.
  • Self-Validating: bisa digunakan untuk mengecek kesesuaian aplikasi jika terjadi perubahan kode.
  • Thorough: sebisa mungkin mencakup keseluruhan kode dan business logic aplikasi.

Untuk hands-on ini, kita akan fokus ke sisi back-end dari aplikasi. Kita akan memanfaatkan teknik berupa mock dan stub untuk membuat kode cakupan setiap test kita menjadi lebih fokus terhadap objek yang benar-benar akan di-test.

Apa itu mock?

Mock adalah objek palsu yang kita bisa gunakan untuk menggunakan objek yang menjadi dependensi dari suatu fungsi/kelas. Mock object dapat kita gunakan untuk tracking, yaitu melihat bagaimana interaksi antara fungsi yang kita test dengan objek dependensi tersebut.

Contoh sederhananya adalah, kita ingin melihat apakah fungsi kita melakukan proses Save Object ke sebuah database. Berikut adalah contoh penggunaan mock di test code:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
    @InjectMocks
    PaymentService service; // Mock object dari PaymentRepository akan ditanam dalam object "service"

    @Mock
    PaymentRepository repository; // Mock object dari PaymentRepository yang akan kita gunakan

    // Test ini bertujuan untuk melihat apakah method "save" pada PaymentRepository dieksekusi setidaknya 1 kali ketika method "create" di PaymentService dieksekusi.
    @Test
    void testSaveObjectExecutedWhenCreateExecuted() {
        service.create("a-01", "Bambang", 20000);
        verify(repository, atLeastOnce()).save(any(Payment.class));
    }

Apa itu stub?

Stub adalah objek palsu yang kita bisa gunakan untuk menyimulasikan keluaran fungsi-fungsi pada objek tersebut. Hal ini sangat berguna jika kita menggunakan library eksternal atau API eksternal, sehingga unit test kita tidak akan membuang waktu dan resource untuk mengakses library atau API tersebut. Kita bisa meminta stub untuk mengembalikan hasil yang kita inginkan untuk diproses oleh fungsi yang kita test. Kita juga bisa meminta stub untuk memberikan error untuk menyimulasikan negative case.

Contoh sederhananya adalah, kita ingin menyimulasikan bahwa terdapat objek Payment di database, tanpa kita harus memasukkan entri Payment ke dalam database terlebih dahulu. Berikut adalah contoh penggunaan stub di test code:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
    @InjectMocks
    PaymentService service; // Stub object dari PaymentRepository akan ditanam dalam object "service"

    @Mock
    PaymentRepository repository; // Stub object dari PaymentRepository yang akan kita gunakan

    // Test ini bertujuan untuk melihat apakah "create" akan mengembalikan error jika payment dengan ID yang sama ("a-01") sudah ada.
    @Test
    void testCreatePaymentReturnsErrorIfPaymentAlreadyExists() {
        // Proses stubbing: jika repository.getById("a-01") dieksekusi, kembalikan objek Payment yang sudah dibuat
        Payment payment = new Payment("a-01", "Bambang", 20000);
        when(repository.getById("a-01")).thenReturn(payment);

        // Cek apakah eksekusi method "create" akan memunculkan exception PaymentAlreadyCreated
        assertThrows(PaymentAlreadyCreated.class, () -> {
            service.create("a-01", "Usep", 100000);
        });
    }

Persiapan Workshop

Untuk mengikuti rangkaian kegiatan workshop hari ini, harap persiapkan tools berikut di komputer anda:

Buat salinan branch main dari repositori Git kode templat workshop hari ini:

1
git clone https://gitlab.cs.ui.ac.id/pmpl/workshops/sibayar.git

Apabila sudah menyalin repositori Git dan telah menyiapkan tools yang dibutuhkan, Anda bisa menjalanakan aplikasi contoh dengan perintah shell berikut:

1
2
3
4
mvn package -DskipTests
cp src/main/resources/application.properties target/application.properties
cd target
java -jar sibayar-0.1.1-SNAPSHOT.jar

Perlu diketahui bahwa aplikasi yang ada pada repo tersebut belum lengkap. Kita akan lengkapi bersama saat tutorial ini.

SIBAYAR: Pembayaran peer-to-peer dengan mekanisme Accept Payment dan Disbursement

SIBAYAR merupakan sistem pembayaran peer-to-peer sederhana yang menggunakan sebuah API payment gateway (dalam kasus ini, Flip) untuk menyalurkan uang. Berikut adalah flow keseluruhan dari sistem SIBAYAR:

  1. User bisa melakukan transfer uang ke User lain, atau ke nomor rekening yang tidak terdaftar di SIBAYAR.
  2. SIBAYAR mengontak API Flip untuk mendapatkan link pembayaran, yang bisa digunakan User untuk membayar transfer tersebut.
  3. User melakukan pembayaran menggunakan link pembayaran yang telah diberikan API Flip ke User melalui SIBAYAR.
  4. Jika user sudah selesai melakukan pembayaran, API Flip akan mengeksekusi payment callback endpoint milik SIBAYAR untuk melanjutkan proses transfer.
  5. Ketika API Flip mengeksekusi payment callback endpoint SIBAYAR, sistem SIBAYAR akan kembali mengontak API Flip untuk transfer uang (disbursement) ke rekening tujuan.
  6. Ketika Flip berhasil melakukan disbursement, API Flip akan mengeksekusi disbursement callback endpoint milik SIBAYAR, sehingga pembayaran tersebut akan ditandai sukses oleh SIBAYAR.

Untuk tutorial hari ini, tidak perlu khawatirkan akses callback dari Flip, karena Flip hanya bisa melakukan callback ketika aplikasi SIBAYAR sudah di-deploy secara publik. Akan tetapi, kita tetap perlu mengimplementasikan keseluruhan sistem SIBAYAR, dengan menyusun tiga controller:

  1. AuthenticationController: untuk proses login dan register akun baru.
  2. PaymentController: untuk melakukan payment baru dan melihat histori payment.
  3. CallbackController: untuk payment callback endpoint dan disbursement callback endpoint yang akan dikontak oleh API Flip.

Membuat Test Suite Class

Untuk proses pembuatan unit test, kita akan memanfaatkan library Mockito. Mockito berfungsi untuk membuat mock dan stub serta meng-inject objek-objek palsu tersebut (dependency injection) ke dalam objek yang akan kita test. Untuk menyusun test suite yang support Mockito, kita perlu tambahkan anotasi @ExtendWith(MockitoExtension.class).

Selain itu, kita perlu membuat fungsi setUp() dengan anotasi @BeforeEach, yang berfungsi sebagai prosedur set up untuk setiap unit test pada test suite. Fungsi setUp() yang dianotasi dengan @BeforeEach akan dijalankan sebelum setiap unit test dijalankan. Dengan fungsi setUp(), kita bisa mengisolasi proses inisiasi setiap unit test agar hasilnya bisa independen, selain itu juga meningkatkan reusability dan konsistensi antar test case karena menggunakan cara inisiasi yang serupa.

Berikut adalah contoh inisiasi test suite untuk class PaymentServiceImpl, yaitu class PaymentServiceImplTest yang dibuat di folder src/test/com/example/sibayar/service/payment:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
package com.example.sibayar.service.payment;

// ... import dependensi yang dibutuhkan untuk test

@ExtendWith(MockitoExtension.class)
class PaymentServiceImplTest {

    @InjectMocks
    private PaymentServiceImpl service;
    @Mock
    private PaymentRepository paymentRepository;
    @Mock
    private UserRepository userRepository;
    @Mock
    User user;

    Payment payment;
    PaymentToUserRequest paymentToUserRequest;
    PaymentToOtherRequest paymentToOtherRequest;
    PaymentLinkResponse paymentLinkResponse;

    // Instansiasi objek-objek yang akan menjadi hasil dari stub
    @BeforeEach
    void setUp() {
        paymentToOtherRequest = PaymentToOtherRequest.builder()
            .destinationName("Pak Bambang")
            .destinationBankCode("bca")
            .destinationAccountNumber("123456789")
            .amount(50000)
            .build();
        paymentToUserRequest = PaymentToUserRequest.builder()
            .destinationUserId(2)
            .amount(50000)
            .build();
        paymentLinkResponse = PaymentLinkResponse.builder()
            .expiredDate(LocalDateTime.of(2023, 11, 4, 23, 59))
            .linkId(1)
            .linkUrl("https://flip.id/bambang")
            .build();
        payment = Payment.builder()
            .id(1)
            .sender(user)
            .destinationName("Pak Bambang")
            .destinationBankCode("bca")
            .destinationAccountNumber("123456789")
            .amount(50000)
            .paymentLinkId(1)
            .paymentLinkUrl("https://flip.id/bambang")
            .expiredDate(LocalDateTime.of(2023, 11, 4, 23, 59))
            .status(String.valueOf(PaymentStatus.WAITING_PAYMENT))
            .build();
    }
}

Dalam kasus ini, terdapat 3 objek yang akan dibuat objek mock/stub-nya, yang ditandai dengan anotasi @Mock:

  1. PaymentRepository: sebuah class yang bertugas untuk melakukan operasi database terhadap tabel Payment. Di Spring Boot, class ini disebut sebagai repository.
  2. UserRepository: sebuah class yang bertugas untuk melakukan operasi database terhadap tabel User. Di Spring Boot, class ini disebut sebagai repository.
  3. User: sebuah class yang bertugas sebagai database model untuk tabel User (pengguna).

Ketiga objek ini kemudian akan di-inject ke PaymentServiceImpl yang akan kita test, ditandai dengan anotasi @InjectMocks.

Terdapat juga 2 objek yang akan menjadi parameter dari fungsi yang akan di-test, yaitu:

  1. PaymentToUserRequest: sebuah Data Transfer Object (DTO) sebagai argumen dari fungsi payToUser, yang datang dari PaymentController.
  2. PaymentToOtherRequest: sebuah Data Transfer Object (DTO) sebagai argumen dari fungsi payToOtherDestination, yang datang dari PaymentController.

Terdapat juga 2 objek yang akan menjadi hasil dari stub, yaitu:

  1. Payment: sebuah objek database model untuk tabel Payment.
  2. PaymentLinkResponse: sebuah objek Data Transfer Object (DTO) untuk menampung hasil kembalian dari Flip API ketika membuat payment link.

Tugas Anda

  • Buat test suite PaymentServiceImplTest sesuai dengan arahan yang diberikan.
    • Test suite terdiri dari objek mock/stub berikut:
      • PaymentRepository
      • UserRepository
      • User
    • Test suite terdiri dari objek untuk parameter fungsi yang di-test berikut:
      • PaymentToUserRequest
      • PaymentToOtherRequest
    • Test suite terdiri dari objek hasil stub berikut:
      • Payment
      • PaymentLinkResponse
    • Buat fungsi setUp() untuk menyusun objek-objek hasil stub.

Latihan Mandiri: Inisiasi test suite CallbackServiceImplTest

  • Buat test suite CallbackServiceImplTest, sejajar dengan test suite PaymentServiceImplTest yang telah dibuat.
    • Test suite terdiri dari objek mock/stub berikut:
      • PaymentRepository
    • Test suite terdiri dari objek untuk parameter fungsi yang di-test berikut:
      • PaymentCallbackRequest
      • DisbursementCallbackRequest
    • Test suite terdiri dari objek hasil stub berikut:
      • Payment
      • DisbursementResponse
    • Buat fungsi setUp() untuk menyusun objek-objek hasil stub.

Melakukan Mock dan Stub untuk Akses Database dan Helper Function Lain

SIBAYAR menggunakan Spring Data JPA untuk mengakses database In-Memory H2. Dalam menggunakan Spring Data JPA, kita perlu membuat repository interface yang berisikan daftar method untuk mengakses database. JPA kemudian akan membuatkan implementasi setiap method secara on the fly sesuai dengan nama method yang kita gunakan. Repository interface tersebut akan menjadi sebuah komponen Spring yang bisa digunakan untuk service yang akan kita buat bersama. Di tutorial hari ini, sudah tersedia repository untuk tabel database Payment yaitu PaymentRepository, dan tabel database User yaitu UserRepository.

Database adalah komponen dependensi pada objek service yang perlu kita isolasi. Programmer pada umumnya akan membuat entri ke database lalu akan menghapusnya kembali ketika test selesai. Akan tetapi, hal tersebut jadi memakan resource lebih dengan perlu adanya testing database yang nyata. Oleh karena itu, dalam menyusun unit test untuk service, kita perlu melakukan mock dan stub komponen Repository. Sebagai contoh, berikut adalah beberapa test case yang bisa Anda gunakan:

Contoh 1: Fungsi payToUser sukses menyimpan dan mengembalikan objek Payment baru

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
    @Test
    void testPayToUserSuccess() {
        when(userRepository.findById(any(Integer.class))).thenReturn(Optional.of(user));
        when(service.getPaymentLink(any(PaymentLinkRequest.class))).thenReturn(paymentLinkResponse);

        Payment result = service.payToUser(1, paymentToUserRequest);

        verify(service, atLeastOnce()).getPaymentLink(any(PaymentLinkRequest.class));
        verify(paymentRepository, atLeastOnce()).save(result);

        assertEquals(paymentLinkResponse.getExpiredDate(), result.getExpiredDate());
        assertEquals(paymentLinkResponse.getLinkId(), result.getPaymentLinkId());
        assertEquals(paymentLinkResponse.getLinkUrl(), result.getPaymentLink());
    }

Berikut adalah penjelasan terkait test case ini:

  1. Di line 3, terdapat stubbing untuk fungsi userRepository.findById(Integer) untuk menyimulasikan bahwa di database terdapat objek mock user.
  2. Di line 4, terdapat stubbing untuk fungsi service.getPaymentLink(PaymentLinkRequest) untuk mengembalikan objek paymentLinkResponse, untuk menyimulasikan kembalian dari pemanggilan API Flip untuk mendapatkan payment link.

Test case ini akan mengecek:

  1. Apakah fungsi payToUser memanggil setidaknya satu kali helper function getPaymentLink untuk akses API Flip? (sintaks verifikasi pemanggilan mock ada di line 8)
  2. Apakah fungsi payToUser memanggil setidaknya satu kali fungsi save pada PaymentRepository untuk menyimpan objek Payment ke dalam database? (sintaks verifikasi pemanggilan mock ada di line 9)
  3. Apakah fungsi payToUser mengembalikan objek Payment, dengan isi yang sama seperti yang dikembalikan oleh API Flip?
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
    @Test
    void testPaymentCallbackWithSuccessfulStatusAndPendingDisbursement() {
        when(paymentRepository.findByPaymentLinkId(any(Integer.class))).thenReturn(Optional.of(payment));
        paymentCallbackRequest.setStatus("SUCCESSFUL");
        when(service.disburseMoney(any(DisbursementRequest.class))).thenReturn(disbursementResponse);
        disbursementResponse.setStatus("PENDING");

        Payment payment = service.paymentCallback(paymentCallbackRequest);

        verify(service, atLeastOnce()).disburseMoney(any(DisbursementRequest.class));
        verify(paymentRepository, atLeastOnce()).save(result);

        assertEquals(PaymentStatus.WAITING_DISBURSEMENT.getStatus(), payment.getStatus());
    }

Fungsi paymentCallback bertujuan untuk meneruskan pembayaran lewat mekanisme Disbursement jika status Payment Link yang diberikan "SUCCESSFUL".

Berikut adalah penjelasan terkait test case ini:

  1. Di line 3, terdapat stubbing untuk fungsi paymentRepository.findByPaymentLinkId(Integer) untuk menyimulasikan bahwa di database terdapat objek payment.
  2. Di line 4, dilakukan set status pada objek paymentCallbackRequest menjadi "SUCCESSFUL", untuk menyimulasikan API Flip memanggil payment callback dengan status "sukses" sehingga proses disbursement bisa dilakukan.
  3. Di line 5, terdapat stubbing untuk fungsi service.disburseMoney(DisbursementRequest) untuk mengembalikan objek paymentLinkResponse.
  4. Di line 6, dilakukan set status pada objek disbursementResponse menjadi "PENDING" untuk menyimulasikan API Flip sedang melakukan proses disbursement.

Test case ini akan mengecek:

  1. Apakah fungsi paymentCallback memanggil setidaknya satu kali helper function disburseMoney untuk akses API Flip? (sintaks verifikasi pemanggilan mock ada di line 10)
  2. Apakah fungsi paymentCallback memanggil setidaknya satu kali fungsi save pada PaymentRepository untuk menyimpan perubahan objek Payment ke dalam database? (sintaks verifikasi pemanggilan mock ada di line 11)
  3. Apakah fungsi paymentCallback mengembalikan objek Payment dengan status berupa "WAITING_DISBURSEMENT"?
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
    @Test
    void testPaymentCallbackWithSuccessfulStatusAndSuccessfulDisbursement() {
        when(paymentRepository.findByPaymentLinkId(any(Integer.class))).thenReturn(Optional.of(payment));
        paymentCallbackRequest.setStatus("SUCCESSFUL");
        when(service.disburseMoney(any(DisbursementRequest.class))).thenReturn(disbursementResponse);
        disbursementResponse.setStatus("DONE");

        Payment payment = service.paymentCallback(paymentCallbackRequest);

        verify(service, atLeastOnce()).disburseMoney(any(DisbursementRequest.class));
        verify(paymentRepository, atLeastOnce()).save(result);

        assertEquals(PaymentStatus.SUCCESS.getStatus(), payment.getStatus());
    }

Berikut adalah penjelasan terkait test case ini:

  1. Di line 3, terdapat stubbing untuk fungsi paymentRepository.findByPaymentLinkId(Integer) untuk menyimulasikan bahwa di database terdapat objek payment.
  2. Di line 4, dilakukan set status pada objek paymentCallbackRequest menjadi "SUCCESSFUL", untuk menyimulasikan API Flip memanggil payment callback dengan status "sukses" sehingga proses disbursement bisa dilakukan.
  3. Di line 5, terdapat stubbing untuk fungsi service.disburseMoney(DisbursementRequest) untuk mengembalikan objek paymentLinkResponse.
  4. Di line 6, dilakukan set status pada objek disbursementResponse menjadi "DONE", untuk menyimulasikan API Flip langsung selesai melakukan disbursement.

Test case ini akan mengecek:

  1. Apakah fungsi paymentCallback memanggil setidaknya satu kali helper function disburseMoney untuk akses API Flip? (sintaks verifikasi pemanggilan mock ada di line 10)
  2. Apakah fungsi paymentCallback memanggil setidaknya satu kali fungsi save pada PaymentRepository untuk menyimpan perubahan objek Payment ke dalam database? (sintaks verifikasi pemanggilan mock ada di line 11)
  3. Apakah fungsi paymentCallback mengembalikan objek Payment dengan status berupa "SUCCESS"?

Tugas Anda

  • Buat test case testPayToUserSuccess sesuai dengan arahan yang sudah diberikan.
    • Test case diletakkan di dalam test suite com.example.sibayar.service.payment.PaymentServiceImplTest.
    • Pastikan test case mengecek tiga hal yang disebutkan di arahan.
  • Buat test case testPaymentCallbackWithSuccessfulStatusAndPendingDisbursement sesuai dengan arahan yang sudah diberikan.
    • Test case diletakkan di com.example.sibayar.service.payment.CallbackServiceImplTest.
    • Pastikan test case mengecek tiga hal yang disebutkan di arahan.
  • Buat test case testPaymentCallbackWithSuccessfulStatusAndSuccessfulDisbursement sesuai dengan arahan yang sudah diberikan.
    • Test case diletakkan di com.example.sibayar.service.payment.CallbackServiceImplTest.
    • Pastikan test case mengecek tiga hal yang disebutkan di arahan.

Latihan Mandiri: Buat sebuah positive case

  • Buat test case testPayToOtherSuccess pada test suite PaymentServiceImplTest untuk mengecek:
    • Apakah fungsi payToOtherDestination memanggil helper function getPaymentLink untuk akses API Flip?
    • Apakah fungsi payToOtherDestination memanggil fungsi save pada PaymentRepository untuk menyimpan objek Payment ke dalam database?
    • Apakah fungsi payToOtherDestination mengembalikan objek Payment, dengan isi yang sama seperti yang dikembalikan oleh API Flip?

Latihan Mandiri: Buat beberapa negative case

  • Buat test case testPaymentCallbackWithSuccessfulStatusButCancelledDisbursement pada test suite CallbackServiceImplTest untuk mengecek:
    • Apakah fungsi paymentCallback memanggil setidaknya satu kali helper function disburseMoney untuk akses API Flip?
    • Apakah fungsi paymentCallback memanggil setidaknya satu kali fungsi save pada PaymentRepository untuk menyimpan perubahan objek Payment ke dalam database?
    • Apakah fungsi paymentCallback mengembalikan objek Payment dengan status berupa "DISBURSEMENT_FAILED"?
    • CATATAN: status pada disbursementResponse harus diganti dengan "CANCELLED".
  • Buat test case testPaymentCallbackWithCancelledStatus pada test suite CallbackServiceImplTest untuk mengecek:
    • Apakah fungsi paymentCallback TIDAK memanggil helper function disburseMoney untuk akses API Flip?
      HINT: gunakan
      1
      verify(service, atMost(0)).disburseMoney(any(DisbursementRequest.class))
      
      untuk mengecek bahwa helper function tidak dipanggil.
    • Apakah fungsi paymentCallback memanggil setidaknya satu kali fungsi save pada PaymentRepository untuk menyimpan perubahan objek Payment ke dalam database?
    • Apakah fungsi paymentCallback mengembalikan objek Payment dengan status berupa "PAYMENT_FAILED"?
    • CATATAN: status pada paymentCallbackRequest harus diganti dengan "CANCELLED".

Menguji Apakah Fungsi Mengeluarkan Exception dalam Suatu Test Case

Unit test tidak hanya mencakup positive case (dalam kasus ini: mengembalikan objek Payment), akan tetapi juga perlu mencakup negative case (dalam kasus ini: mengeluarkan sebuah exception).

Untuk mengecek apakah aplikasi mengeluarkan exception, kita bisa memanfaatkan fungsi assertThrows. Sebagai contoh, berikut adalah beberapa negative case yang bisa Anda gunakan:

Contoh 1: payToUser pada PaymentServiceImpl akan mengembalikan error ketika user ID dari sender dan destination sama.

1
2
3
4
5
6
    @Test
    void testPayToUserFailWhenSenderAndDestinationAreTheSame() {
        assertThrows(SelfPaymentException.class, () -> {
            service.payToUser(paymentToUserRequest.getDestinationUserId(), paymentToUserRequest);
        });
    }

Test case ini akan mengecek apakah exception SelfPaymentException dikeluarkan jika user ID dari sender dan destination sama.

Contoh 2: payToUser pada PaymentServiceImpl akan mengembalikan error jika Flip API sedang tidak bisa diakses.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
    @Test
    void testPayToUserThrowsExceptionWhenAPIIsUnreachable() {
        when(userRepository.findById(any(Integer.class))).thenReturn(Optional.of(user));
        when(service.getPaymentLink(any(PaymentLinkRequest.class))).thenThrow(new APIUnreachableException("flip"));

        assertThrows(APIUnreachableException.class, () -> {
            service.payToUser(1, paymentToUserRequest);
        });
        verify(service, atLeastOnce()).getPaymentLink(any(PaymentLinkRequest.class));
    }

Berikut adalah penjelasan mengenai stubbing pada test case ini:

  1. Di line 3, terdapat stubbing untuk fungsi userRepository.findById(Integer) untuk mengembalikan objek mock user.
  2. Di line 4, terdapat stubbing untuk fungsi service.getPaymentLink(PaymentLinkRequest) untuk melempar exception APIUnreachableException.

Test case ini akan mengecek:

  1. Apakah fungsi payToUser meneruskan exception APIUnreachableException? (pengecekan di line 6)
  2. Apakah fungsi payToUser memanggil helper function getPaymentLink untuk akses API Flip? (sintaks verifikasi pemanggilan mock ada di line 9)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
    @Test
    void testPaymentCallbackWithSuccessfulStatusThrowsExceptionWhenAPIIsUnreachable() {
        when(paymentRepository.findByPaymentLinkId(any(Integer.class))).thenReturn(Optional.of(payment));
        paymentCallbackRequest.setStatus("SUCCESSFUL");
        when(service.disburseMoney(any(DisbursementRequest.class))).thenThrow(new APIUnreachableException("flip"));

        assertThrows(APIUnreachableException.class, () -> {
            service.paymentCallback(paymentCallbackRequest);
        });
        verify(service, atLeastOnce()).disburseMoney(any(DisbursementRequest.class));
    }

Berikut adalah penjelasan mengenai stubbing pada test case ini:

  1. Di line 3, terdapat stubbing untuk fungsi paymentRepository.findByPaymentLinkId(Integer) untuk mengembalikan objek payment.
  2. Di line 4, dilakukan set status pada objek paymentCallbackRequest menjadi "SUCCESSFUL", karena akses API Flip untuk disbursement hanya akan diakses jika status payment link sudah SUCCESSFUL.
  3. Di line 5, Selain itu, juga terdapat stubbing untuk fungsi service.disburseMoney(DisbursementRequest) untuk melempar exception APIUnreachableException. Ini untuk menyimulasikan ketika API Flip sedang tidak bisa diakses.

Test case ini akan mengecek:

  1. Apakah fungsi payToUser meneruskan exception APIUnreachableException? (pengecekan di line 8)
  2. Apakah fungsi payToUser memanggil helper function disburseMoney untuk akses API Flip? (sintaks verifikasi pemanggilan mock ada di line 11)

Tugas Anda

  • Buat test case testPayToUserFailWhenSenderAndDestinationAreTheSame sesuai dengan arahan yang sudah diberikan.
    • Test case diletakkan di dalam test suite com.example.sibayar.service.payment.PaymentServiceImplTest.
    • Pastikan test case mengecek exception yang di-throw seperti yang disebutkan di arahan.
  • Buat test case testPayToUserThrowsExceptionWhenAPIIsUnreachable sesuai dengan arahan yang sudah diberikan.
    • Test case diletakkan di com.example.sibayar.service.payment.PaymentServiceImplTest.
    • Pastikan test case mengecek tiga hal yang disebutkan di arahan.
  • Buat test case testPaymentCallbackWithSuccessfulStatusThrowsExceptionWhenAPIIsUnreachable sesuai dengan arahan yang sudah diberikan.
    • Test case diletakkan di com.example.sibayar.service.payment.CallbackServiceImplTest.
    • Pastikan test case mengecek tiga hal yang disebutkan di arahan.

Latihan Mandiri: Buat negative case

  • Buat test case testPayToOtherThrowsExceptionWhenAPIIsUnreachable pada test suite PaymentServiceImplTest untuk mengecek:
    • Apakah fungsi payToOtherDestination meneruskan exception APIUnreachableException?
    • Apakah fungsi payToOtherDestination memanggil setidaknya satu kali helper function getPaymentLink untuk akses API Flip?

Refactoring: Bagaimana jika SIBAYAR bisa menggunakan lebih dari satu Payment Gateway?

Ternyata kode SIBAYAR yang asli belum cukup modular. Bagaimana jika suatu hari kita tidak menggunakan Flip sebagai payment gateway? Salah satu cara yang dapat kita lakukan adalah memisahkan helper function getPaymentLink dan disburseMoney menjadi sebuah class tersendiri di bawah package com.example.sibayar.external.paymentgateway. Lalu, bagaimana cara kita menyesuaikan implementasi dan unit test kita ketika ada perubahan desain tersebut?

Pada bagian ini, kita akan melakukan proses refactoring beserta penyesuaian unit test yang perlu dilakukan.

Tugas Anda

  • Buat interface baru PaymentGatewayAPI di package com.example.sibayar.external.paymentgateway, dengan menggunakan snippet berikut:
    1
    2
    3
    4
    public interface PaymentGatewayAPI {
        PaymentLinkResponse getPaymentLink(PaymentLinkRequest request);
        DisbursementResponse disburseMoney(DisbursementRequest request);
    }
    
    Tujuan dari interface PaymentGatewayAPI adalah untuk memastikan bahwa semua class penghubung dengan API payment gateway dapat diakses dengan cara yang sama.
  • Buat class baru FlipAPI di packakge com.example.sibayar.external.paymentgateway, dengan menggunakan snippet berikut:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    @Service("flipAPI")
    @RequiredArgsConstructor
    public class FlipAPI implements PaymentGatewayAPI {
        @Value("${sibayar.flip.baseUrl}")
        private String baseUrl;
        @Value("${sibayar.flip.apiKey}")
        private String apiKey;
        private HttpClient client = HttpClient.newHttpClient();
    
        public PaymentLinkResponse getPaymentLink(PaymentLinkRequest request) {
            // TODO: Pindahkan isi fungsi getPaymentLink di PaymentServiceImpl ke sini.
        }
    
        public DisbursementResponse disburseMoney(DisbursementRequest request) {
            // TODO: Pindahkan isi fungsi disburseMoney di CallbackServiceImpl ke sini.
        }
    
        private String getBasicAuthHeader(String username, String password) {
            String valueToEncode = username + ":" + password;
            return "Basic " + Base64.getEncoder()
          .encodeToString(valueToEncode.getBytes());
        }
    }
    
    Lakukan juga beberapa hal berikut:

    • Pindahkan juga isi method getPaymentLink yang sebelumnya ada di com.example.sibayar.service.payment.PaymentServiceImpl, ke dalam method getPaymentLink yang ada di FlipAPI.
    • Pindahkan juga isi method disburseMoney yang sebelumnya ada di com.example.sibayar.service.payment.CallbackServiceImpl, ke dalam method getPaymentLink yang ada di FlipAPI.
  • Pada PaymentServiceImpl dan CallbackServiceImpl, buat koneksi baru ke implementasi dari PaymentGatewayAPI dengan cara membuat instance variable baru. Gunakan snippet berikut untuk diletakkan di definisi class dari PaymentServiceImpl dan CallbackServiceImpl.

    1
    2
        @Qualifier("flipAPI")
        private final PaymentGatewayAPI api;
    
    @Qualifier("flipAPI") akan otomatis melakukan dependency injection sehingga variabel api akan berisi objek dari FlipAPI.

  • Gunakan api untuk mengakses fungsi getPaymentLink dan disburseMoney yang telah dipindahkan ke FlipAPI. Misal dari sebelumnya:
    1
            PaymentLinkResponse apiResponse = getPaymentLink(apiRequest);
    
    menjadi seprti berikut:
    1
            PaymentLinkResponse apiResponse = api.getPaymentLink(apiRequest);
    
  • Tambahkan objek mock untuk PaymentGatewayAPI pada test suite PaymentServiceImplTest dan CallbackServiceImplTest dengan menggunakan snippet berikut:
    1
    2
        @Mock
        private PaymentGatewayAPI flipAPI;
    
  • Gunakan objek mock flipAPI untuk menggantikan pemanggilan fungsi getPaymentLink dan disburseMoney pada setiap test case. Misal dari sebelumnya:
    1
    2
    3
        when(service.disburseMoney(any(DisbursementRequest.class))).thenReturn(disbursementResponse);
        // ...
        verify(service, atLeastOnce()).disburseMoney(any(DisbursementRequest.class));
    
    menjadi seperti berikut:
    1
    2
    3
        when(flipAPI.disburseMoney(any(DisbursementRequest.class))).thenReturn(disbursementResponse);
        // ...
        verify(flipAPI, atLeastOnce()).disburseMoney(any(DisbursementRequest.class));
    

Latihan Mandiri Tambahan

  • Lengkapi semua test case hingga Line Coverage menyentuh 100%.
  • Buat kode test case yang meaningful dan thorough. Cek apakah dependensi eksternal seperti repository atau payment gateway API dipanggil sesuai kebutuhan setiap test case.
  • Integrasikan project Anda dengan SonarQube.
    Contoh project SIBAYAR yang sudah memenuhi Line Coverage 100%: Screenshot SonarQube SIBAYAR

Penutup

Kita sudah bersama-sama membuat unit test untuk Service, lengkap dengan cara menggunakan mock dan stub.

Untuk bahan diskusi saat refleksi:

  • Apakah Line Coverage 100% menjamin tidak ada bug? Apakah Line Coverage 100% menjamin aspek FIRST principle terutama aspek Thorough?
  • Bagaimana jika kita langsung melakukan modifikasi database atau mengakses langsung library atau API eksternal saat kita melakukan unit test? Apa dampaknya bagi konsistensi hasil dari unit test? Apa dampaknya bagi FIRST principle pada unit test yang dibuat, terutama aspek Fast, Isolated, dan Repeatable?
  • Apa kesulitan yang dialami Bapak/Ibu ketika menjalani tutorial ini?

Untuk hari ketiga, kita akan mendalami mengenai Functional Test dan Behaviour-Driven Development (BDD).

Reflection Notes

Mengenai FIRST Principle

  1. Fast
    • Proses testing harus dilakukan secara cepat, oleh karena itu testing Service dan komponen lain di Spring tidak menggunakan MockMvc layaknya di Controller. Hal ini karena di Service, kita cukup fokus dengan logic kita yang sudah terpisah dari dependensi framework Spring Boot.
    • Mengapa kita harus melakukan mock dan stub untuk akses database?
      Karena kita tidak ingin akses database memperlambat proses testing kita. Bahkan, kita sebisa mungkin menghindari alokasi resource tambahan untuk database terpisah hanya untuk unit test.
  2. Isolated
    • Mengapa kita harus melakukan mock dan stub untuk akses database, library, atau API eksternal?
      Karena kita tidak ingin hasil dari proses eksekusi suatu unit test memengaruhi hasil unit test lain. Dengan melakukan mock dan stub terhadap dependensi eksternal, kita tidak mengotori data pada dependensi eksternal tersebut dengan dummy data dari unit test kita.
  3. Repeatable
    • Aspek ini punya kaitan erat dengan aspek Isolated. Dengan unit test yang terisolasi, kita bisa mendapatkan hasil yang konsisten meskipun testing dilakukan berulang-ulang kali.
  4. Self-Validating
    • Aspek ini sudah otomatis ter-cover dengan penggunaan testing library JUnit, yang juga telah kita gunakan di tutorial.
    • Pastikan saja setiap unit test memiliki 3A:
      • Arrange: Persiapkan semua data dan argumen untuk testing.
      • Act: Jalankan fungsi yang akan di-test.
      • Assert: Lakukan pengecekan terhadap hasil keluaran dari fungsi yang di-test. Ini juga termasuk jika fungsi tersebut mengeluarkan exception.
  5. Thorough
    • Pastikan semua happy path dan edge case telah diuji.
    • Lihat kembali Acceptance Criteria dari suatu use case untuk fungsi yang akan di-test.

Apakah Coverage 100% menjamin kode bersih dari bug?

Tidak bisa menjamin. Inilah pentingnya aspek Thorough pada FIRST Principle.

Kalaupun aspek Thorough pada unit test sudah terpenuhi, kita juga masih tidak bisa menjamin program bebas bug ketika proses integrasi. Oleh karena itu dibutuhkan integration test (yang akan dibahas lebih lanjut pada Day 3).

Contoh sederhananya adalah pada test untuk Controller di SIBAYAR. Ternyata walaupun Code Coverage dan Branch Coverage-nya sudah 100%, kita belum melakukan test untuk otorisasi (endpoint X hanya bisa diakses oleh role A). Proses test untuk otorisasi di Spring Boot tidak bisa dilakukan dengan unit test, namun dengan integration test. Dalam integration test, kita akan menjalankan aplikasinya secara keseluruhan, yang tentunya akan membutuhkan waktu eksekusi lebih lama dibandingkan unit test.


Last update: 2023-11-06 08:30:30
Created: 2023-10-17 02:48:29