Java: Unit Test dengan BDD
Thu. Jul 31st, 2025 08:58 AM15 mins read
Java: Unit Test dengan BDD
Source: Ideogram - Unit Test BDD

Walaupun testing ini adalah tugas utamanya Tester atau QA, tapi dari sisi Developer juga perlu melakukan testing sebelum dites oleh Tester agar bugs bisa diketahui lebih awal dan dijalankan secara otomatis. Testing memastikan proses aplikasi yang kita kembangkan berjalan sesuai requirement. Testing juga membantu kita untuk mengetahui efek dari perubahan yang kita lakukan terhadap code yang ada saat ini, apakah nyenggol ke algoritma lain atau tidak. Terutama pada edge case yang kadang tidak terdeteksi saat melakukan perubahan. Misalnya user dengan role tertentu ga boleh melakukan pengeditan terhadap pesanan. Tapi setelah ada fitur baru yang ga berhubungan dengan role tersebut, edge case terkait user dengan role tersebut jadi bisa melakukan pengeditan akibat dari senggolan fitur baru tersebut. Tanpa testing bisa saja edge case ini kelewat tanpa disadari😱.

Jenis Testing

Ada beberapa jenis testing yang umum dilakukan:

Unit Test

Yang dites di sini hanyalah algoritmanya saja tanpa mengintegrasikannya dengan komponen lain seperti database, hard disk, API lain, dan lainnya. Jadi, ga ada integrasi secara langsung di sini, melainkan hasil dari integrasi tersebut akan di-mock datanya dengan data dummy. Dengan begitu proses running unit test jadi lebih cepat. Dengan kelebihan itu lah unit test sering jadi pilihan karena proses running-nya lebih cepat dan scope-nya lebih kecil sehingga logic yang nge-bug bisa dideteksi lebih cepat. Kekurangannya, karena ga ada integrasi maka kita tidak bisa mengecek efek code terhadap integrasi dengan komponen lain. Seperti pengecekan query ke database, kita jadi ga tahu apakah query tersebut berjalan dengan benar setelah di-develop.

Integration Test

Sesuai namanya, yang dites kali ini adalah integrasinya dengan komponen lain seperti database, hard disk, API lain, dan lainnya. Tapi, yang diintegrasikan bukan integrasi dengan komponen aslinya, melainkan menggunakan komponen dummy seperti menggunakan database dengan in-memory H2, pemanggilan API dengan WireMock, atau bikin temporary files untuk ngetes file di hard disk. Di sini kita bisa mengetes integrasinya seperti memastikan query yang dieksekusi sudah benar atau belum atau mengetes pemanggilan API sesuai request yang diperlukan. Kekurangannya, ini lebih lambat daripada unit test karena kita benar-benar menjalankan semua komponen yang ingin diintegrasikan seperti simulasi system beneran.

End-to-End Test

Kalau ini yang dites scopenya bukanlah dari sisi code lagi, melainkan suatu flow sesuai skenario bisnis yang dilakukan dari awal sampai akhir. Seperti pada skenario bikin order, skenarionya adalah: login -> cari produk -> check out -> bayar -> order dikonfirmasi. Untuk ini biasanya yang ngetes adalah Tester. Dari sisi Developer kalau udah ada unit test biasanya hanya smoke test aja setelah deploy, sisanya dicek oleh Tester.

Other tests

Selain 3 jenis tes di atas, sebenarnya masih banyak lagi jenisnya seperti Smoke Test untuk mengecek sekilas apakah aplikasinya baik-baik saja dan ga ada error, Regression Test untuk mengecek apakah code baru ga menyenggol fitur lain setelah deploy, Performance Test untuk mengecek performa aplikasi, Security Test untuk mengecek keamanan aplikasi, Acceptance Test untuk pengecekan kesuluruhan skenario persis sebagaimana yang akan dilakukan oleh user aslinya, A/B testing untuk membandingkan 2 jenis versi berbeda dari fitur yang sama, dan beberapa tes lainnya.

BDD Unit Test

Sesuai judul, fokus pada tulisan ini adalah unit test saja dan gw akan melakukannya menggunakan BDD (Behavioral Driven Development), yaitu unit test dengan style yang mendeskripsikan testingnya menggunakan bahasa yang mendekati natural English. Ini jadi style unit test favorit gw karena menurut gw lebih mudah dipahami dan assertionnya menggunakan method chaining sehingga untuk satu value bisa di-assert berkali-kali dalam satu statement.

Setup Project

Sebelum memulai, di sini gw akan setup project menggunakan dependency lombok untuk memudahkan penggunaan builder dan mengurangi boilerplate, junit-jupiter-api sebagai framework unit testing yang kita pakai, junit-jupiter-params sebagai add-ons dari JUnit untuk testing menggunakan parameter, assertj-core untuk assertion test, mockito-core untuk mocking data, dan mockito-junit-jupiter untuk integrasi Mockito dengan JUnit.

Dependencies pom.xml
Copy
<properties>
	<mockito.version>3.12.4</mockito.version>
	<lombok.version>1.18.38</lombok.version>
	<assertj.version>3.27.3</assertj.version>
	<junit-jupiter.version>5.13.4</junit-jupiter.version>
</properties>

<dependencies>
	<dependency>
		<groupId>org.projectlombok</groupId>
		<artifactId>lombok</artifactId>
		<version>${lombok.version}</version>
	</dependency>
	<dependency>
		<groupId>org.junit.jupiter</groupId>
		<artifactId>junit-jupiter-api</artifactId>
		<version>${junit-jupiter.version}</version>
		<scope>test</scope>
	</dependency>
	<dependency>
		<groupId>org.junit.jupiter</groupId>
		<artifactId>junit-jupiter-params</artifactId>
		<version>${junit-jupiter.version}</version>
		<scope>test</scope>
	</dependency>
	<dependency>
		<groupId>org.assertj</groupId>
		<artifactId>assertj-core</artifactId>
		<version>${assertj.version}</version>
		<scope>test</scope>
	</dependency>
	<dependency>
			<groupId>org.mockito</groupId>
			<artifactId>mockito-core</artifactId>
			<version>${mockito.version}</version>
			<scope>test</scope>
	</dependency>
	<dependency>
		<groupId>org.mockito</groupId>
		<artifactId>mockito-junit-jupiter</artifactId>
		<version>${mockito.version}</version>
		<scope>test</scope>
	</dependency>
</dependencies>

Use Case

Use case yang akan dilakukan adalah flow registrasi user sebagai berikut:

  • Password ga boleh null dan ga boleh kurang dari 8 karakter;
  • Email ga boleh null dan ga boleh kosong;
  • Email ga boleh terdaftar sebelumnya;
  • Jika request valid, maka akan menyimpan data user ke database dengan active status = true, beserta kolom createdAt dengan waktu sekarang;
  • Kemudian akan mengirimkan notikasi email bahwa registrasi berhasil;
  • Lalu mengirimkan response berupa email yang didaftarkan beserta message tanda berhasil;

Contoh Code

Di sini gw menggunakan Command Design Pattern untuk membungkus sebuah action menjadi sebuah objek agar scope codenya lebih kecil, dan Dependency Injection agar proses mocking datanya lebih gampang.

UserRequest Class
Copy
@Builder
@Value
public class UserRequest{
	String email;
	String fullName;
	String password;
	LocalDate birthDate;
}
UserEntity Class
Copy
@Builder
@Value
public class UserEntity{
	String email;
	String fullName;
	String password;
	LocalDate birthDate;
	boolean active;
	Instant createdAt;
}
EmailRequest Class
Copy
@Value
public class EmailRequest{
	String email;
	String fullName;
}
UserRegistrationResponse Class
Copy
@Value
public class UserRegistrationResponse{
	String email;
	String message;
}
UserGateway Interface
Copy
public interface UserGateway{
	boolean existsByEmail(String email);
	void save(UserEntity user);
}
NotificationClient Interface
Copy
public interface NotificationClient{
	void sendWelcomeEmail(EmailRequest request);
}
UserRegistrationPresenter Interface
Copy
public interface UserRegistrationPresenter{
	void present(UserRegistrationResponse response);
}
UserRegistrationUseCase Class
Copy
@RequiredArgsConstructor
public class UserRegistrationUseCase{
	private final UserGateway userGateway;
	private final NotificationClient notificationClient;

	public void registerUser(UserRequest userRequest, UserRegistrationPresenter presenter){
		if(userRequest.getPassword() == null || userRequest.getPassword().length() < 8){
			throw new IllegalArgumentException("Invalid password");
		}
		if(userRequest.getEmail() == null || userRequest.getEmail().trim().isEmpty()){
			throw new IllegalArgumentException("Invalid email");
		}
		if(userGateway.existsByEmail(userRequest.getEmail())){
			throw new IllegalArgumentException("Email already exists");
		}

		UserEntity user = UserEntity.builder()
				.email(userRequest.getEmail())
				.fullName(userRequest.getFullName())
				.password(userRequest.getPassword())
				.birthDate(userRequest.getBirthDate())
				.active(true)
				.createdAt(Instant.now())
				.build();
		userGateway.save(user);

		notificationClient.sendWelcomeEmail(new EmailRequest(user.getEmail(), user.getFullName()));

		presenter.present(new UserRegistrationResponse(user.getEmail(), "User registered successfully"));
	}

}

Contoh Unit Test

Sekarang kita coba bikin unit test dari Use Case di atas. Di sini gw akan menggunakan beberapa anotasi berikut:

  1. @ExtendWith(MockitoExtension.class) berfungsi agar kita bisa menggunakan anotasi Mockito;
  2. @Mock berfungsi untuk mocking dependency agar method yang kita eksekusi bisa diatur untuk mengembalikan data dummy yang diinginkan tanpa mengeksekusi objek aslinya;
  3. @InjectMocks berfungsi untuk membuat objek baru dengan dependency dari objek Mock yang dibuat menggunakan @Mock;
  4. @Captor berfungsi untuk menangkap objek argument dari method yang dieksekusi untuk dites;
  5. @Test berfungsi untuk menandai method yang akan dieksekusi oleh JUnit;
  6. @ParameterizedTest berfungsi untuk menandai method yang akan dieksekusi oleh JUnit dengan parameter yang bisa ditentukan;
  7. @NullAndEmptySource digunakan bersamaan dengan @ParameterizedTest untuk mengirimkan parameter dengan value null dan kosong pada unit test;
  8. @ValueSource(strings = {"abc"}) digunakan bersamaan dengan @ParameterizedTest untuk mengirimkan parameter dengan value yang diinginkan, sebagai contoh gw ingin mengirimkan parameter dengan value abc;

Biasanya, nama method dari unit test formatnya given_when_then di mana given adalah keterangan dari input data yang disiapkan, when adalah hal yang dieksekusi, dan then adalah ekspektasi yang diharapkan. Misalnya jika request email invalid ketika mengeksekusi register user, maka ekspektasinya adalah throw exception. Nama methodnya jadi gini: givenInvalidEmail_whenRegisterUser_thenThrowException(). Berhubung gw menggunakan Command Design Pattern, format when ga gw tulis pada method karena scope yang akan dieksekusi sudah pasti tentang register user doang.

Valid Request

Case pertama yang akan dites adalah ketika request yang digunakan sesuai requirement dan ekspektasinya berhasil diproses tanpa error.

UserRegistrationUseCaseTest Class
Copy
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;

import static org.assertj.core.api.BDDSoftAssertions.thenSoftly;
import static org.mockito.BDDMockito.*;

@ExtendWith(MockitoExtension.class)
class UserRegistrationUseCaseTest {
	public static final String EMAIL = "peter@parker.com";
	public static final String FULL_NAME = "Peter";
	public static final String PASSWORD = "12345678";
	public static final LocalDate BIRTH_DATE = LocalDate.of(1919, 1, 1);

	@Mock
	UserGateway userRepository;

	@Mock
	NotificationClient notificationClient;

	@Mock
	UserRegistrationPresenter userRegistrationPresenter;

	@InjectMocks
	UserRegistrationUseCase userRegistrationUseCase;

	@Captor
	ArgumentCaptor<UserEntity> userEntityCaptor;

	@Captor
	ArgumentCaptor<UserRegistrationResponse> responseCaptor;

	@Test
	void givenValidRequest_thenRegisterUserSuccessfully() {
		// given
		UserRequest userRequest = UserRequest.builder()
				.email(EMAIL)
				.fullName(FULL_NAME)
				.password(PASSWORD)
				.birthDate(BIRTH_DATE)
				.build();
		willReturn(false).given(userRepository).existsByEmail(anyString());

		// when
		userRegistrationUseCase.registerUser(userRequest, userRegistrationPresenter);

		// then
		then(userRepository).should(times(1)).save(userEntityCaptor.capture());
		then(notificationClient).should(times(1)).sendWelcomeEmail(any(EmailRequest.class));
		then(userRegistrationPresenter).should(times(1)).present(responseCaptor.capture());
		thenSoftly(and -> {
			UserEntity userEntity = userEntityCaptor.getValue();
			and.then(userEntity.getEmail()).isNotNull().isEqualTo(EMAIL);
			and.then(userEntity.getFullName()).isNotNull().isEqualTo(FULL_NAME);
			and.then(userEntity.getBirthDate()).isNotNull().isEqualTo(BIRTH_DATE);
			and.then(userEntity.getCreatedAt()).isNotNull().isCloseTo(Instant.now(), Assertions.within(Duration.ofHours(1)));
			and.then(userEntity.isActive()).isTrue();

			UserRegistrationResponse response = responseCaptor.getValue();
			and.then(response).isNotNull();
			and.then(response.getEmail()).isNotNull().isEqualTo(EMAIL);
			and.then(response.getMessage()).isNotNull().isEqualTo("User registered successfully");
		});
	}
}

Static method dari BDDSoftAssertions.thenSoftly dan semua static method dari BDDMockito.* perlu di-import untuk mengurangi boilerplate penulisan class dari static method dari class tersebut yang dipakai karena beberapa static method akan digunakan berkali-kali seperti then(), anyString(), any(EmailRequest.class), willReturn(), times(), dan sebagainya.

Dengan BDD codenya lebih mendekati natural English seperti will return false given userRepository exists by email, and then user active is true, and then response is not null, atau and then response email is not null and is equal to constant EMAIL.

Code di atas gw bagi jadi 3 bagian:

  • given adalah bagian preparation untuk menyiapkan data dan mocking;
  • when adalah bagian execution untuk mengeksekusi method yang akan dites;
  • then adalah bagian assertion untuk ngetes memastikan hasilnya sesuai ekspektasi;

Pada code willReturn(false).given(userRepository).existsByEmail(anyString()); artinya method userRepository.existsByEmail() dengan argument String apa pun saat dieksekusi akan mengembalikan false. Di sini lah pentingnya Dependency Injection untuk memudahkan unit test agar mudah di-mock. Kalau objek dependency dibuat di dalam use case, ini sulit dilakukan. Apalagi kalau pakai static method yang berintegrasi dengan database atau API lain, walaupun masih bisa diakali tapi mocking-nya repot dan lambat🫣.

Pada code then(userRepository).should(times(1)).save(userEntityCaptor.capture()); artinya memverifikasi bahwa method userRepository.save() dengan parameter UserEntity hanya akan dieksekusi sekali. userEntityCaptor.capture() berfungsi untuk menangkap objek UserEntity yang dikirim pada parameter method tersebut untuk dites valuenya saat assertion. Hal yang sama juga berlaku pada saat verifikasi notificationClient menggunakan class EmailRequest dengan value apa pun dan userRegistrationPresenter dengan parameter UserRegistrationResponse yang valuenya bisa ditangkap dengan responseCaptor.

thenSoftly() adalah method dari AssertJ untuk melakukan assertion memastikan ekspektasi beberapa value. Kelebihan saat semua assertion dibungkus menggunakan method ini adalah semua assertion akan dieksekusi meskipun salah satu assertion ada yang salah sehingga kita tahu dengan sekali tes ada berapa assertion yang salah. Tanpa method ini, jika ada salah satu assertion salah maka assertion lain di bawahnya jadi ga bisa dites, kita jadi ga bisa tau selain yang satu itu assertion mana lagi yang salah dan harus test berulang kali.

userEntityCaptor.getValue() berfungsi untuk mendapatkan value yang ditangkap menggunakan captor pada saat verifikasi method sebelumnya. Di sini kita bisa test menggunakan soft assertion dari AssertJ menggunakan method and.then() untuk memastikan value yang diproses oleh method userRepository.save() sudah sesuai requirement seperti email, full name, birth date, status active, hingga creation time. Hal yang sama juga dilakukan pada responseCaptor.getValue() untuk memastikan response yang dikirim pada presenter sesuai requirement.

Exists Email

Pada method ini yang dites adalah jika email yang diinput sudah terdaftar, maka akan throw exception.

UserRegistrationUseCaseTest Class
Copy
@Test
void givenExistsEmail_thenThrowException() {
	// given
	UserRequest userRequest = UserRequest.builder()
			.email(EMAIL)
			.fullName(FULL_NAME)
			.password(PASSWORD)
			.birthDate(BIRTH_DATE)
			.build();
	willReturn(true).given(userRepository).existsByEmail(EMAIL);

	//when
	thenSoftly(and -> {
		and.thenExceptionOfType(IllegalArgumentException.class)
				.isThrownBy(() -> userRegistrationUseCase.registerUser(userRequest, userRegistrationPresenter))
				.withMessageContaining("Email already exists");
	});

	//then
	then(userRepository).should(times(1)).existsByEmail(anyString());
	then(userRepository).should(never()).save(any());
	then(notificationClient).shouldHaveNoInteractions();
	then(userRegistrationPresenter).shouldHaveNoInteractions();
}

Di sini gw mock bahwa method userRepository.existsByEmail() dengan argument String apa pun akan mengembalikan true, sehingga artinya email tersebut sudah terdaftar. Untuk memastikan code tersebut throw IllegalArgumentException, maka bisa gunakan method thenExceptionOfType(IllegalArgumentException.class) untuk membungkus eksekusi use case. Di sini juga bisa dilakukan assertion bahwa pesan errornya adalah Email already exists.

Sesuai flow, kita pastikan userRepository.existsByEmail() hanya dieksekusi sekali. then(userRepository).should(never()).save(any()); artinya kita pastikan method tersebut ga akan dieksekusi karena adanya exception.

then(notificationClient).shouldHaveNoInteractions(); artinya kita pastikan dependency notificationClient ga akan ada interaksi sama sekali karena exception terjadi sebelum terjadi interaksi dengan dependency ini. Hal yang sama juga berlaku pada userRegistrationPresenter.

Invalid Email

Di sini yang dites adalah jika email yang diinput null atau kosong.

UserRegistrationUseCaseTest Class
Copy
@ParameterizedTest
@NullAndEmptySource
void givenInvalidEmail_thenThrowException(String email) {
	// given
	UserRequest userRequest = UserRequest.builder()
			.email(email)
			.fullName(FULL_NAME)
			.password(PASSWORD)
			.birthDate(BIRTH_DATE)
			.build();

	//when
	thenSoftly(and -> {
		and.thenExceptionOfType(IllegalArgumentException.class)
				.isThrownBy(() -> userRegistrationUseCase.registerUser(userRequest, userRegistrationPresenter))
				.withMessageContaining("Invalid email");
	});

	//then
	then(userRepository).shouldHaveNoInteractions();
	then(notificationClient).shouldHaveNoInteractions();
	then(userRegistrationPresenter).shouldHaveNoInteractions();
}

Kali ini yang digunakan adalah anotasi @ParameterizedTest beserta @NullAndEmptySource. Ini nantinya unit test akan dilakukan 2x pada test case ini, yaitu menggunakan value null dan value kosong pada email. Sisanya kurang lebih sama dengan test di atas.

Invalid Password

Sekarang yang dites adalah jika passwordnya null, kosong, atau kurang dari 8 karakter.

UserRegistrationUseCaseTest Class
Copy
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {"abc"})
void givenInvalidPassword_thenThrowException(String password) {
	// given
	UserRequest userRequest = UserRequest.builder()
			.email(EMAIL)
			.fullName(FULL_NAME)
			.password(password)
			.birthDate(BIRTH_DATE)
			.build();

	//when
	thenSoftly(and -> {
		and.thenExceptionOfType(IllegalArgumentException.class)
				.isThrownBy(() -> userRegistrationUseCase.registerUser(userRequest, userRegistrationPresenter))
				.withMessageContaining("Invalid password");
	});

	//then
	then(userRepository).shouldHaveNoInteractions();
	then(notificationClient).shouldHaveNoInteractions();
	then(userRegistrationPresenter).shouldHaveNoInteractions();
}

Yang jadi pembeda di sini adalah anotasi @ValueSource(strings = {"abc"}). Artinya ada tambahan satu tes lagi selain menggunakan value null dan value kosong, yaitu abc. Sisanya kurang lebih sama dengan kasus sebelumnya.

Perubahan Requirement

Misalkan setelah development ada tambahan requirement seperti: Password harus memiliki minimal satu huruf dan satu angka.

Maka perlu ubah code-nya jadi seperti ini:

UserRegistrationUseCase Class
Copy
@RequiredArgsConstructor
public class UserRegistrationUseCase{
	private final UserGateway userGateway;
	private final NotificationClient notificationClient;

	public void registerUser(UserRequest userRequest, UserRegistrationPresenter presenter){
		if(!hasDigitAndLetter(userRequest.getPassword()) || userRequest.getPassword() == null || userRequest.getPassword().length() < 8){
			throw new IllegalArgumentException("Invalid password");
		}
		if(userRequest.getEmail() == null || userRequest.getEmail().trim().isEmpty()){
			throw new IllegalArgumentException("Invalid email");
		}
		if(userGateway.existsByEmail(userRequest.getEmail())){
			throw new IllegalArgumentException("Email already exists");
		}

		UserEntity user = UserEntity.builder()
				.email(userRequest.getEmail())
				.fullName(userRequest.getFullName())
				.password(userRequest.getPassword())
				.birthDate(userRequest.getBirthDate())
				.active(true)
				.createdAt(Instant.now())
				.build();
		userGateway.save(user);

		notificationClient.sendWelcomeEmail(new EmailRequest(user.getEmail(), user.getFullName()));

		presenter.present(new UserRegistrationResponse(user.getEmail(), "User registered successfully"));
	}

	private boolean hasDigitAndLetter(String password) {
		boolean hasLetter = false;
		boolean hasDigit = false;

		for (char c : password.toCharArray()) {
			if (Character.isLetter(c)) {
				hasLetter = true;
			} else if (Character.isDigit(c)) {
				hasDigit = true;
			}
		}

		return hasLetter && hasDigit;
	}

}

Sample password di unit testnya juga perlu diganti menggunakan password yang valid sesuai requirement terbaru seperti abc12345678 dan perlu tambahkan parameterized test menggunakan value angka dan huruf kurang dari 8 karakter, angka saja, dan huruf saja pada test case invalid password.

UserRegistrationUseCaseTest Class
Copy
@ExtendWith(MockitoExtension.class)
class UserRegistrationUseCaseTest{
	public static final String EMAIL = "peter@parker.com";
	public static final String FULL_NAME = "peter";
	public static final String PASSWORD = "abc12345678";
	public static final LocalDate BIRTH_DATE = LocalDate.of(1919, 1, 1);

	@Mock
	UserGateway userRepository;

	@Mock
	NotificationClient notificationClient;

	@Mock
	UserRegistrationPresenter userRegistrationPresenter;

	@InjectMocks
	UserRegistrationUseCase userService;

	@Captor
	ArgumentCaptor<UserEntity> userEntityCaptor;

	@Captor
	ArgumentCaptor<UserRegistrationResponse> responseCaptor;

	@ParameterizedTest
	@NullAndEmptySource
	@ValueSource(strings = {"abc123", "abcdefghijk", "1234567890"})
	void givenInvalidPassword_thenThrowException(String password) {
		// given
		UserRequest userRequest = UserRequest.builder()
				.email(EMAIL)
				.fullName(FULL_NAME)
				.password(password)
				.birthDate(BIRTH_DATE)
				.build();

		//when
		thenSoftly(and -> {
			and.thenExceptionOfType(IllegalArgumentException.class)
					.isThrownBy(() -> userService.registerUser(userRequest, userRegistrationPresenter))
					.withMessageContaining("Invalid password");
		});

		//then
		then(userRepository).shouldHaveNoInteractions();
		then(notificationClient).shouldHaveNoInteractions();
		then(userRegistrationPresenter).shouldHaveNoInteractions();
	}
}

Setelah dijalankan, unit test tersebut gagal karena NullPointerException🫣. Di sini lah manfaat unit test, kita bisa tahu saat perubahan yang kita lakukan ternyata ada bugs tanpa perlu run aplikasinya dan test secara manual. Kita dituntut untuk berpikir pragmatis🧠. Tanpa unit test mungkin kita optimis aja untuk langsung commit code karena requirement tambahannya sangat simple. Atau biasanya kita perlu run aplikasinya lalu test secara manual via postman.

Sekarang kita fixing method registerUser di atas agar aman dari NullPointerException.

Fix registerUser Method
Copy
public void registerUser(UserRequest userRequest, UserRegistrationPresenter presenter){
	if(userRequest.getPassword() == null || userRequest.getPassword().length() < 8 || !hasDigitAndLetter(userRequest.getPassword())){
		throw new IllegalArgumentException("Invalid password");
	}
	if(userRequest.getEmail() == null || userRequest.getEmail().trim().isEmpty()){
		throw new IllegalArgumentException("Invalid email");
	}
	if(userGateway.existsByEmail(userRequest.getEmail())){
		throw new IllegalArgumentException("Email already exists");
	}

	UserEntity user = UserEntity.builder()
			.email(userRequest.getEmail())
			.fullName(userRequest.getFullName())
			.password(userRequest.getPassword())
			.birthDate(userRequest.getBirthDate())
			.active(true)
			.createdAt(Instant.now())
			.build();
	userGateway.save(user);

	notificationClient.sendWelcomeEmail(new EmailRequest(user.getEmail(), user.getFullName()));

	presenter.present(new UserRegistrationResponse(user.getEmail(), "User registered successfully"));
}

Verdict

Dengan unit test, setiap ada yang mengubah code tersebut bisa terlihat bakal nyenggol bagian mana. Kita bisa review ulang di bagian mana perubahan itu nyenggol dan perlu fixing code beserta unit testnya. Ini cukup membantu saat bekerja sama dengan tim. Kadang kita ga hafal algoritmanya secara keseluruhan, apalagi waktu pertama kali ngoding terkait class tersebut. Kalau udah ada unit test kita jadi tau lebih awal apa aja yang perlu dihandle saat melakukan perubahan tanpa takut ada bugs yang ga kita ketahui. Kita jadi tau case mana aja yang akan error efek perubahan tersebut. Di luar sana banyak juga yang pro-kontra terkait unit testing karena membuat load kerja jadi bertambah, tapi gw bagian yang pro-nya. Unit test jadi bagian development yang cukup penting. Ini sangat berguna saat refactor code. Bagian menyeramkan saat refactor itu adalah saat code yang sebelumnya baik-baik aja tiba-tiba jadi error setelah refactor😱. Kalau ada unit test kita jadi tau apa-apa aja yang perlu kita perhatikan untuk memastikan proses refactor tidak mengganggu fitur yang ada saat ini. Unit test bisa jadi dokumen berjalan saat development. Oh ya, saat melakukan unit test, pastikan tidak ada integrasi apa pun yang terkoneksi secara langsung dengan komponen lain karena itu bagian integration test dan itu bisa membuat unit test jadi lambat, sedangkan tujuan unit test itu adalah agar bisa dieksekusi dengan cepat. Perlu diperhatikan juga, yang perlu ditest itu bukan sebanyak apa line yang harus di-cover, melainkan sebanyak edge case yang bisa terjadi dalam flow dari class tersebut. Makanya sebelum memulai coding kita perlu brainstorm soal requirement bersama Product Manager dan QA terkait what if di dalam flow-nya dan skenario-skenario yang masih abu-abu. Dengan begini aplikasi yang dihasilkan nanti juga akan berkualitas.

© 2025 · Ferry Suhandri