-
Notifications
You must be signed in to change notification settings - Fork 6
feature: add AccountServiceTest unit tests with Mockito #245
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: DevOps
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,259 @@ | ||
| package com.example.bankapp.service; | ||
|
|
||
| import com.example.bankapp.model.Account; | ||
| import com.example.bankapp.model.Transaction; | ||
| import com.example.bankapp.repository.AccountRepository; | ||
| import com.example.bankapp.repository.TransactionRepository; | ||
| import org.junit.jupiter.api.BeforeEach; | ||
| import org.junit.jupiter.api.Test; | ||
| import org.junit.jupiter.api.extension.ExtendWith; | ||
| import org.mockito.ArgumentCaptor; | ||
| import org.mockito.InjectMocks; | ||
| import org.mockito.Mock; | ||
| import org.mockito.junit.jupiter.MockitoExtension; | ||
| import org.springframework.security.core.userdetails.UserDetails; | ||
| import org.springframework.security.crypto.password.PasswordEncoder; | ||
|
|
||
| import java.math.BigDecimal; | ||
| import java.time.LocalDateTime; | ||
| import java.util.List; | ||
| import java.util.Optional; | ||
|
|
||
| import static org.junit.jupiter.api.Assertions.assertEquals; | ||
| import static org.junit.jupiter.api.Assertions.assertThrows; | ||
| import static org.junit.jupiter.api.Assertions.assertSame; | ||
| import static org.mockito.ArgumentMatchers.any; | ||
| import static org.mockito.Mockito.never; | ||
| import static org.mockito.Mockito.times; | ||
| import static org.mockito.Mockito.verify; | ||
| import static org.mockito.Mockito.when; | ||
|
|
||
| @ExtendWith(MockitoExtension.class) | ||
| class AccountServiceTest { | ||
|
|
||
| @Mock | ||
| private AccountRepository accountRepository; | ||
|
|
||
| @Mock | ||
| private TransactionRepository transactionRepository; | ||
|
|
||
| @Mock | ||
| private PasswordEncoder passwordEncoder; | ||
|
|
||
| @InjectMocks | ||
| private AccountService accountService; | ||
|
|
||
| private Account account; | ||
|
|
||
| @BeforeEach | ||
| void setUp() { | ||
| account = new Account(); | ||
| account.setId(1L); | ||
| account.setUsername("alice"); | ||
| account.setPassword("secret"); | ||
| account.setBalance(new BigDecimal("100")); | ||
| } | ||
|
|
||
| // ----- deposit() ----- | ||
|
|
||
| @Test | ||
| void deposit_shouldIncreaseBalanceByAmount() { | ||
| accountService.deposit(account, new BigDecimal("50")); | ||
|
|
||
| assertEquals(new BigDecimal("150"), account.getBalance()); | ||
| verify(accountRepository).save(account); | ||
| verify(transactionRepository).save(any(Transaction.class)); | ||
| } | ||
|
|
||
| @Test | ||
| void deposit_shouldCreateTransactionWithCorrectType() { | ||
| accountService.deposit(account, new BigDecimal("50")); | ||
|
|
||
| ArgumentCaptor<Transaction> captor = ArgumentCaptor.forClass(Transaction.class); | ||
| verify(transactionRepository).save(captor.capture()); | ||
|
|
||
| Transaction saved = captor.getValue(); | ||
| assertEquals("Deposit", saved.getType()); | ||
| assertEquals(new BigDecimal("50"), saved.getAmount()); | ||
| } | ||
|
|
||
| // ----- withdraw() ----- | ||
|
|
||
| @Test | ||
| void withdraw_shouldDecreaseBalanceByAmount() { | ||
| accountService.withdraw(account, new BigDecimal("30")); | ||
|
|
||
| assertEquals(new BigDecimal("70"), account.getBalance()); | ||
| verify(accountRepository).save(account); | ||
| verify(transactionRepository).save(any(Transaction.class)); | ||
| } | ||
|
|
||
| @Test | ||
| void withdraw_shouldThrowRuntimeExceptionWhenInsufficientFunds() { | ||
| account.setBalance(new BigDecimal("10")); | ||
|
|
||
| RuntimeException ex = assertThrows(RuntimeException.class, | ||
| () -> accountService.withdraw(account, new BigDecimal("50"))); | ||
|
|
||
| assertEquals("Insufficient funds", ex.getMessage()); | ||
| verify(accountRepository, never()).save(any(Account.class)); | ||
| verify(transactionRepository, never()).save(any(Transaction.class)); | ||
| } | ||
|
|
||
| @Test | ||
| void withdraw_shouldCreateTransactionWithCorrectType() { | ||
| accountService.withdraw(account, new BigDecimal("30")); | ||
|
|
||
| ArgumentCaptor<Transaction> captor = ArgumentCaptor.forClass(Transaction.class); | ||
| verify(transactionRepository).save(captor.capture()); | ||
|
|
||
| Transaction saved = captor.getValue(); | ||
| assertEquals("Withdrawal", saved.getType()); | ||
| assertEquals(new BigDecimal("30"), saved.getAmount()); | ||
| } | ||
|
|
||
| // ----- transferAmount() ----- | ||
|
|
||
| @Test | ||
| void transferAmount_shouldDeductFromSenderAndAddToRecipient() { | ||
| account.setBalance(new BigDecimal("200")); | ||
|
|
||
| Account recipient = new Account(); | ||
| recipient.setId(2L); | ||
| recipient.setUsername("bob"); | ||
| recipient.setBalance(new BigDecimal("0")); | ||
|
|
||
| when(accountRepository.findByUsername("bob")).thenReturn(Optional.of(recipient)); | ||
|
|
||
| accountService.transferAmount(account, "bob", new BigDecimal("50")); | ||
|
|
||
| assertEquals(new BigDecimal("150"), account.getBalance()); | ||
| assertEquals(new BigDecimal("50"), recipient.getBalance()); | ||
| } | ||
|
|
||
| @Test | ||
| void transferAmount_shouldThrowWhenInsufficientFunds() { | ||
| account.setBalance(new BigDecimal("10")); | ||
|
|
||
| RuntimeException ex = assertThrows(RuntimeException.class, | ||
| () -> accountService.transferAmount(account, "bob", new BigDecimal("50"))); | ||
|
|
||
| assertEquals("Insufficient funds", ex.getMessage()); | ||
| verify(transactionRepository, never()).save(any(Transaction.class)); | ||
| } | ||
|
|
||
| @Test | ||
| void transferAmount_shouldThrowWhenRecipientNotFound() { | ||
| account.setBalance(new BigDecimal("200")); | ||
|
|
||
| when(accountRepository.findByUsername("bob")).thenReturn(Optional.empty()); | ||
|
|
||
| RuntimeException ex = assertThrows(RuntimeException.class, | ||
| () -> accountService.transferAmount(account, "bob", new BigDecimal("50"))); | ||
|
|
||
| assertEquals("Recipient account not found", ex.getMessage()); | ||
| } | ||
|
|
||
| @Test | ||
| void transferAmount_shouldCreateTwoTransactionRecords() { | ||
| account.setBalance(new BigDecimal("200")); | ||
|
|
||
| Account recipient = new Account(); | ||
| recipient.setId(2L); | ||
| recipient.setUsername("bob"); | ||
| recipient.setBalance(new BigDecimal("0")); | ||
|
|
||
| when(accountRepository.findByUsername("bob")).thenReturn(Optional.of(recipient)); | ||
|
|
||
| accountService.transferAmount(account, "bob", new BigDecimal("50")); | ||
|
|
||
| verify(transactionRepository, times(2)).save(any(Transaction.class)); | ||
| } | ||
|
|
||
| // ----- registerAccount() ----- | ||
|
|
||
| @Test | ||
| void registerAccount_shouldCreateAccountWithZeroBalance() { | ||
| when(accountRepository.findByUsername("newuser")).thenReturn(Optional.empty()); | ||
| when(passwordEncoder.encode("password")).thenReturn("encoded"); | ||
| when(accountRepository.save(any(Account.class))).thenAnswer(invocation -> invocation.getArgument(0)); | ||
|
|
||
| accountService.registerAccount("newuser", "password"); | ||
|
|
||
| ArgumentCaptor<Account> captor = ArgumentCaptor.forClass(Account.class); | ||
| verify(accountRepository).save(captor.capture()); | ||
|
|
||
| Account saved = captor.getValue(); | ||
| assertEquals("newuser", saved.getUsername()); | ||
| assertEquals("encoded", saved.getPassword()); | ||
| assertEquals(BigDecimal.ZERO, saved.getBalance()); | ||
| } | ||
|
|
||
| @Test | ||
| void registerAccount_shouldThrowWhenUsernameAlreadyExists() { | ||
| when(accountRepository.findByUsername("alice")).thenReturn(Optional.of(account)); | ||
|
|
||
| RuntimeException ex = assertThrows(RuntimeException.class, | ||
| () -> accountService.registerAccount("alice", "password")); | ||
|
|
||
| assertEquals("Username already exists", ex.getMessage()); | ||
| verify(accountRepository, never()).save(any(Account.class)); | ||
| } | ||
|
|
||
| // ----- findAccountByUsername() ----- | ||
|
|
||
| @Test | ||
| void findAccountByUsername_shouldReturnAccountWhenFound() { | ||
| when(accountRepository.findByUsername("alice")).thenReturn(Optional.of(account)); | ||
|
|
||
| Account result = accountService.findAccountByUsername("alice"); | ||
|
|
||
| assertSame(account, result); | ||
| } | ||
|
|
||
| @Test | ||
| void findAccountByUsername_shouldThrowWhenNotFound() { | ||
| when(accountRepository.findByUsername("ghost")).thenReturn(Optional.empty()); | ||
|
|
||
| RuntimeException ex = assertThrows(RuntimeException.class, | ||
| () -> accountService.findAccountByUsername("ghost")); | ||
|
|
||
| assertEquals("Account not found", ex.getMessage()); | ||
| } | ||
|
|
||
| // ----- loadUserByUsername() ----- | ||
|
|
||
| @Test | ||
| void loadUserByUsername_shouldReturnUserDetailsWhenFound() { | ||
| when(accountRepository.findByUsername("alice")).thenReturn(Optional.of(account)); | ||
|
|
||
| UserDetails userDetails = accountService.loadUserByUsername("alice"); | ||
|
|
||
| assertEquals("alice", userDetails.getUsername()); | ||
| assertEquals("secret", userDetails.getPassword()); | ||
| } | ||
|
|
||
| @Test | ||
| void loadUserByUsername_shouldThrowWhenUsernameNotFound() { | ||
| when(accountRepository.findByUsername("ghost")).thenReturn(Optional.empty()); | ||
|
|
||
| assertThrows(RuntimeException.class, | ||
| () -> accountService.loadUserByUsername("ghost")); | ||
| } | ||
|
Comment on lines
+237
to
+242
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🚩 Test asserts RuntimeException instead of UsernameNotFoundException for loadUserByUsername The Was this helpful? React with 👍 or 👎 to provide feedback.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed — this is intentional. The test asserts |
||
|
|
||
| // ----- getTransactionHistory() ----- | ||
|
|
||
| @Test | ||
| void getTransactionHistory_shouldReturnTransactionsForAccount() { | ||
| Transaction t1 = new Transaction(new BigDecimal("50"), "Deposit", LocalDateTime.now(), account); | ||
| Transaction t2 = new Transaction(new BigDecimal("30"), "Withdrawal", LocalDateTime.now(), account); | ||
| List<Transaction> transactions = List.of(t1, t2); | ||
|
|
||
| when(transactionRepository.findByAccountId(1L)).thenReturn(transactions); | ||
|
|
||
| List<Transaction> result = accountService.getTransactionHistory(account); | ||
|
|
||
| assertEquals(2, result.size()); | ||
| assertEquals(transactions, result); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚩 loadUserByUsername test checks RuntimeException but method contract declares UsernameNotFoundException
The
loadUserByUsername_shouldThrowWhenUsernameNotFoundtest assertsRuntimeException.class, which is technically correct for what the implementation does —findAccountByUsernameatAccountService.java:87callsorElseThrow(() -> new RuntimeException("Account not found"))atAccountService.java:35, throwing before theUsernameNotFoundExceptioncheck on line 88-89 (which is dead code). However, theUserDetailsServicecontract specifies thatUsernameNotFoundExceptionshould be thrown. This means the test is encoding a pre-existing bug in the service rather than testing the expected Spring Security contract. If the service is later fixed to throwUsernameNotFoundException, this test will break.Was this helpful? React with 👍 or 👎 to provide feedback.
Debug
Playground