Skip to content
Home / Skills / Testing / Unit Testing
TE

Unit Testing

Testing core v1.0.0

Unit Testing

Overview

Unit tests validate individual classes or functions in complete isolation from external dependencies. They form the base of the test pyramid — fast, deterministic, and numerous. Every domain service, utility class, mapper, and business rule should have comprehensive unit tests. In the full-lifecycle pipeline, @testing-qa generates unit test code for all services created in Phase 6 (backend) and Phase 7 (frontend).


Key Concepts

Unit Test Anatomy (Given-When-Then)

@Test
@DisplayName("should calculate total with discount when order has VIP customer")
void shouldCalculateTotalWithDiscount() {
    // Given — arrange preconditions
    var customer = CustomerFixture.vip();
    var items = List.of(
        OrderItem.of("Widget", Money.of(100)),
        OrderItem.of("Gadget", Money.of(200))
    );
    
    // When — execute the behavior
    var total = pricingService.calculateTotal(customer, items);
    
    // Then — assert expected outcome
    assertThat(total).isEqualTo(Money.of(270)); // 10% VIP discount
}

Test Double Types

TypePurposeExample
StubReturns canned answerswhen(repo.findById(1L)).thenReturn(Optional.of(user))
MockVerifies interactionsverify(emailService).send(any(Email.class))
SpyWraps real object, records callsspy(realService)
FakeWorking implementation (simplified)In-memory repository
DummyFills parameter, never usednew Object() for unused param

Test Naming Conventions

ConventionExample
BDD-styleshould_ReturnDiscount_When_CustomerIsVIP
Method-basedcalculateTotal_vipCustomer_appliesDiscount
@DisplayName@DisplayName("should apply 10% discount for VIP customers")

Best Practices

  1. One logical concept per test — Don’t test create, update, and delete in one method
  2. Use @DisplayName — Human-readable test descriptions for documentation
  3. Use AssertJ over HamcrestassertThat(result).isEqualTo(expected) is more readable
  4. Use test data builders — Factory methods for complex objects, not constructors in every test
  5. Mock at boundaries — Mock external dependencies (DB, HTTP, MQ), not internal classes
  6. Test exception cases — Use assertThatThrownBy() for expected failures
  7. Keep tests fast — Under 50ms each; no I/O, no sleep, no real network
  8. No logic in tests — No if/else/loops in test methods; each path is a separate test

Code Examples

✅ Good: Java Unit Test (JUnit 5 + Mockito + AssertJ)

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock
    private OrderRepository orderRepository;
    
    @Mock
    private PaymentGateway paymentGateway;
    
    @InjectMocks
    private OrderService orderService;
    
    @Nested
    @DisplayName("placeOrder")
    class PlaceOrder {
        
        @Test
        @DisplayName("should create order and process payment for valid request")
        void shouldCreateOrderAndProcessPayment() {
            // Given
            var request = OrderFixture.validRequest();
            var savedOrder = OrderFixture.pendingOrder(request);
            when(orderRepository.save(any(Order.class))).thenReturn(savedOrder);
            when(paymentGateway.charge(any())).thenReturn(PaymentResult.success());
            
            // When
            var result = orderService.placeOrder(request);
            
            // Then
            assertThat(result.status()).isEqualTo(OrderStatus.CONFIRMED);
            verify(orderRepository).save(argThat(order -> 
                order.getCustomerId().equals(request.customerId())
            ));
            verify(paymentGateway).charge(argThat(charge ->
                charge.amount().equals(request.totalAmount())
            ));
        }
        
        @Test
        @DisplayName("should throw InsufficientFundsException when payment fails")
        void shouldThrowWhenPaymentFails() {
            // Given
            var request = OrderFixture.validRequest();
            when(orderRepository.save(any())).thenReturn(OrderFixture.pendingOrder(request));
            when(paymentGateway.charge(any())).thenReturn(PaymentResult.declined());
            
            // When & Then
            assertThatThrownBy(() -> orderService.placeOrder(request))
                .isInstanceOf(InsufficientFundsException.class)
                .hasMessageContaining("Payment declined");
        }
    }
}

✅ Good: React Unit Test (Jest + React Testing Library)

import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { OrderForm } from './OrderForm';

describe('OrderForm', () => {
  it('should display validation error when quantity is zero', async () => {
    // Given
    render(<OrderForm onSubmit={jest.fn()} />);
    
    // When
    fireEvent.change(screen.getByLabelText('Quantity'), {
      target: { value: '0' },
    });
    fireEvent.click(screen.getByRole('button', { name: /submit/i }));
    
    // Then
    await waitFor(() => {
      expect(screen.getByText('Quantity must be at least 1'))
        .toBeInTheDocument();
    });
  });
  
  it('should call onSubmit with form data when valid', async () => {
    // Given
    const onSubmit = jest.fn();
    render(<OrderForm onSubmit={onSubmit} />);
    
    // When
    fireEvent.change(screen.getByLabelText('Product'), {
      target: { value: 'Widget' },
    });
    fireEvent.change(screen.getByLabelText('Quantity'), {
      target: { value: '3' },
    });
    fireEvent.click(screen.getByRole('button', { name: /submit/i }));
    
    // Then
    await waitFor(() => {
      expect(onSubmit).toHaveBeenCalledWith({
        product: 'Widget',
        quantity: 3,
      });
    });
  });
});

❌ Bad: Common Unit Test Mistakes

@Test
void test1() {
    // Multiple concepts in one test
    var user = new User("test@example.com", "Test");
    assertNotNull(userService.create(user));     // Create
    user.setName("Updated");
    assertNotNull(userService.update(user));      // Update
    userService.delete(user.getId());             // Delete
    assertNull(userService.findById(user.getId())); // Verify
    // Should be 4 separate tests
}

@Test
void testWithRealDatabase() {  
    // Not a unit test — has real I/O dependency
    var repo = new JdbcUserRepository(dataSource);
    // This belongs in integration tests
}

Anti-Patterns

  1. Testing implementation — Verifying private method calls instead of public behavior
  2. Excessive mocking — Mocking everything including the SUT (system under test)
  3. Test per method — Tests should map to behaviors, not method names
  4. Magic valuesassertEquals(42, result) without explaining why 42
  5. Shared mutable state — Static fields or shared objects across tests
  6. Ignoring edge cases — Only testing the happy path

Testing Strategies

  • TDD (red-green-refactor) — Write failing test → make it pass → refactor
  • BDD naming — Use should_expectedBehavior_when_condition pattern
  • Parameterized tests@ParameterizedTest + @CsvSource for data-driven tests
  • Nested test classes@Nested to group tests by method/behavior

References