Skip to main content

Feature 4: Test Coverage Enhancement

Purpose: This guide provides step-by-step instructions for adding comprehensive test coverage to the Springular boilerplate.

Dependencies:

  • Should be implemented after Features 6, 7, 8 (security, reliability, configuration fixes)
  • Tests validate that security and reliability fixes work correctly

Related Documents:


Overview

What This Guide Covers

Test Coverage Enhancement adds comprehensive tests to ensure code quality and prevent regressions:

  • Integration tests for critical flows (authentication, payments, user management)
  • Unit tests for service layer (AuthenticationService, StripeService, EmailService)
  • Repository tests with H2 test database
  • API endpoint tests with MockMvc or RestAssured
  • Frontend component tests (replace stubs with real tests)
  • Test data builders and fixtures

What's Included:

  • Backend integration test classes
  • Backend unit test classes
  • Repository test classes
  • API endpoint test classes
  • Frontend component tests
  • Test data builders
  • Test configuration

What's NOT Included:

  • ❌ E2E tests (separate concern, could be added later)
  • ❌ Performance tests (separate concern)
  • ❌ Security penetration tests (separate concern)

Prerequisites

  • Springular boilerplate setup
  • Understanding of JUnit 5 and Mockito
  • Understanding of Spring Boot testing
  • Understanding of Jasmine/Karma for frontend
  • Features 6, 7, 8 implemented (security, reliability, configuration)

Current State

Codebase Exploration Findings:

  • Only 1 backend test: SpringularApplicationTests.java (context load test)
  • 33 frontend test files (.spec.ts) - mostly stubs
  • No integration tests
  • No API endpoint tests
  • No repository tests
  • No test data builders

Test Coverage: Less than 5% (mostly placeholder tests)


Implementation Steps

Step 1: Add Test Dependencies

File: server/build.gradle

Purpose: Ensure all necessary test dependencies are available.

Add to dependencies (most should already be present):

dependencies {
// ... existing dependencies ...

// Testing
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
testImplementation 'com.h2database:h2'

// MockMvc or RestAssured for API tests
testImplementation 'io.rest-assured:rest-assured:5.3.2'
testImplementation 'io.rest-assured:json-path:5.3.2'
testImplementation 'io.rest-assured:spring-mock-mvc:5.3.2'
}

Why These Dependencies:

  • spring-boot-starter-test - Includes JUnit 5, Mockito, AssertJ
  • spring-security-test - Security testing utilities
  • h2 - In-memory database for tests
  • rest-assured - API testing library (alternative to MockMvc)

Step 2: Create Test Data Builders

File: server/src/test/java/com/saas/springular/test/TestDataBuilder.java (NEW)

Purpose: Create reusable test data builders for clean test setup.

Create builder class:

package com.saas.springular.test;

import com.saas.springular.user.domain.User;
import com.saas.springular.user.domain.Role;

import java.time.LocalDateTime;
import java.util.Set;

/**
* Test data builder for creating test objects with sensible defaults.
* Use builder pattern to override only what's needed for each test.
*/
public class TestDataBuilder {

/**
* Creates a default test user with common settings.
*/
public static UserBuilder user() {
return new UserBuilder();
}

public static class UserBuilder {
private String email = "test@example.com";
private String password = "Test123!@#";
private String firstName = "Test";
private String lastName = "User";
private boolean enabled = true;
private Set<Role> roles = Set.of(Role.USER);

public UserBuilder email(String email) {
this.email = email;
return this;
}

public UserBuilder password(String password) {
this.password = password;
return this;
}

public UserBuilder firstName(String firstName) {
this.firstName = firstName;
return this;
}

public UserBuilder lastName(String lastName) {
this.lastName = lastName;
return this;
}

public UserBuilder enabled(boolean enabled) {
this.enabled = enabled;
return this;
}

public UserBuilder roles(Set<Role> roles) {
this.roles = roles;
return this;
}

public UserBuilder admin() {
this.roles = Set.of(Role.ADMIN);
return this;
}

public User build() {
User user = new User();
user.setEmail(email);
user.setPassword(password);
user.setFirstName(firstName);
user.setLastName(lastName);
user.setEnabled(enabled);
user.setRoles(roles);
return user;
}
}
}

Usage Example:

// Create default test user
User user = TestDataBuilder.user().build();

// Create admin user
User admin = TestDataBuilder.user()
.email("admin@example.com")
.admin()
.build();

// Create disabled user
User disabledUser = TestDataBuilder.user()
.enabled(false)
.build();

Step 3: Create Unit Tests for Service Layer

File: server/src/test/java/com/saas/springular/user/api/AuthenticationServiceTest.java (NEW)

Purpose: Unit test AuthenticationService with mocks.

Create test class:

package com.saas.springular.user.api;

import com.saas.springular.common.security.JwtTokenProvider;
import com.saas.springular.common.security.UserPrincipal;
import com.saas.springular.test.TestDataBuilder;
import com.saas.springular.user.domain.User;
import com.saas.springular.user.domain.UserRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.crypto.password.PasswordEncoder;

import java.util.Optional;

import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;

@ExtendWith(MockitoExtension.class)
@DisplayName("AuthenticationService Tests")
class AuthenticationServiceTest {

@Mock
private AuthenticationManager authenticationManager;

@Mock
private UserRepository userRepository;

@Mock
private JwtTokenProvider jwtTokenProvider;

@Mock
private PasswordEncoder passwordEncoder;

@InjectMocks
private AuthenticationServiceImpl authenticationService;

private User testUser;

@BeforeEach
void setUp() {
testUser = TestDataBuilder.user()
.email("test@example.com")
.password("encodedPassword123")
.build();
testUser.setId(1L);
}

@Test
@DisplayName("loginUser_ValidCredentials_ReturnsJwtResponse")
void loginUser_ValidCredentials_ReturnsJwtResponse() {
// Arrange
String email = "test@example.com";
String password = "Test123!@#";
Authentication authentication = mock(Authentication.class);
UserPrincipal userPrincipal = UserPrincipal.create(testUser);

when(authenticationManager.authenticate(any(UsernamePasswordAuthenticationToken.class)))
.thenReturn(authentication);
when(authentication.getPrincipal()).thenReturn(userPrincipal);
when(userRepository.findById(testUser.getId())).thenReturn(Optional.of(testUser));
when(jwtTokenProvider.generateToken(authentication)).thenReturn("access-token-123");
when(jwtTokenProvider.generateRefreshToken(authentication)).thenReturn("refresh-token-456");

// Act
JwtResponse response = authenticationService.loginUser(email, password);

// Assert
assertThat(response).isNotNull();
assertThat(response.getAccessToken()).isEqualTo("access-token-123");
assertThat(response.getRefreshToken()).isEqualTo("refresh-token-456");
assertThat(response.getTokenType()).isEqualTo("Bearer");

verify(authenticationManager).authenticate(any(UsernamePasswordAuthenticationToken.class));
verify(jwtTokenProvider).generateToken(authentication);
verify(jwtTokenProvider).generateRefreshToken(authentication);
}

@Test
@DisplayName("loginUser_InvalidCredentials_ThrowsBadCredentialsException")
void loginUser_InvalidCredentials_ThrowsBadCredentialsException() {
// Arrange
String email = "test@example.com";
String password = "WrongPassword";

when(authenticationManager.authenticate(any(UsernamePasswordAuthenticationToken.class)))
.thenThrow(new BadCredentialsException("Invalid credentials"));

// Act & Assert
assertThatThrownBy(() -> authenticationService.loginUser(email, password))
.isInstanceOf(BadCredentialsException.class)
.hasMessageContaining("Invalid credentials");

verify(authenticationManager).authenticate(any(UsernamePasswordAuthenticationToken.class));
verify(jwtTokenProvider, never()).generateToken(any());
}

@Test
@DisplayName("registerUser_NewUser_SavesUserSuccessfully")
void registerUser_NewUser_SavesUserSuccessfully() {
// Arrange
SignupRequest request = new SignupRequest();
request.setEmail("newuser@example.com");
request.setPassword("NewPass123!@#");
request.setFirstName("New");
request.setLastName("User");

when(userRepository.existsByEmail(request.getEmail())).thenReturn(false);
when(passwordEncoder.encode(request.getPassword())).thenReturn("encodedNewPassword");
when(userRepository.save(any(User.class))).thenAnswer(invocation -> {
User user = invocation.getArgument(0);
user.setId(2L);
return user;
});

// Act
authenticationService.registerUser(request);

// Assert
verify(userRepository).existsByEmail(request.getEmail());
verify(passwordEncoder).encode(request.getPassword());
verify(userRepository).save(argThat(user ->
user.getEmail().equals("newuser@example.com") &&
user.getFirstName().equals("New") &&
user.getLastName().equals("User") &&
user.getPassword().equals("encodedNewPassword")
));
}

@Test
@DisplayName("registerUser_ExistingEmail_ThrowsException")
void registerUser_ExistingEmail_ThrowsException() {
// Arrange
SignupRequest request = new SignupRequest();
request.setEmail("existing@example.com");
request.setPassword("Pass123!@#");

when(userRepository.existsByEmail(request.getEmail())).thenReturn(true);

// Act & Assert
assertThatThrownBy(() -> authenticationService.registerUser(request))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Email already in use");

verify(userRepository).existsByEmail(request.getEmail());
verify(userRepository, never()).save(any());
}
}

Key Patterns:

  • Use @ExtendWith(MockitoExtension.class) for Mockito support
  • Use @Mock for dependencies
  • Use @InjectMocks for the class under test
  • Use @BeforeEach for test setup
  • Use @DisplayName for readable test names
  • Test naming: methodName_Scenario_ExpectedBehavior
  • Use AssertJ assertions (assertThat())
  • Verify mock interactions

Step 4: Create Repository Tests

File: server/src/test/java/com/saas/springular/user/domain/UserRepositoryTest.java (NEW)

Purpose: Test repository methods with actual H2 database.

Create test class:

package com.saas.springular.user.domain;

import com.saas.springular.test.TestDataBuilder;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
import org.springframework.test.context.ActiveProfiles;

import java.util.Optional;

import static org.assertj.core.api.Assertions.*;

@DataJpaTest
@ActiveProfiles("test")
@DisplayName("UserRepository Tests")
class UserRepositoryTest {

@Autowired
private TestEntityManager entityManager;

@Autowired
private UserRepository userRepository;

@Test
@DisplayName("findByEmail_ExistingUser_ReturnsUser")
void findByEmail_ExistingUser_ReturnsUser() {
// Arrange
User user = TestDataBuilder.user()
.email("repo-test@example.com")
.build();
entityManager.persist(user);
entityManager.flush();

// Act
Optional<User> found = userRepository.findByEmail("repo-test@example.com");

// Assert
assertThat(found).isPresent();
assertThat(found.get().getEmail()).isEqualTo("repo-test@example.com");
assertThat(found.get().getFirstName()).isEqualTo("Test");
}

@Test
@DisplayName("findByEmail_NonExistingUser_ReturnsEmpty")
void findByEmail_NonExistingUser_ReturnsEmpty() {
// Act
Optional<User> found = userRepository.findByEmail("nonexistent@example.com");

// Assert
assertThat(found).isEmpty();
}

@Test
@DisplayName("existsByEmail_ExistingUser_ReturnsTrue")
void existsByEmail_ExistingUser_ReturnsTrue() {
// Arrange
User user = TestDataBuilder.user()
.email("exists@example.com")
.build();
entityManager.persist(user);
entityManager.flush();

// Act
boolean exists = userRepository.existsByEmail("exists@example.com");

// Assert
assertThat(exists).isTrue();
}

@Test
@DisplayName("existsByEmail_NonExistingUser_ReturnsFalse")
void existsByEmail_NonExistingUser_ReturnsFalse() {
// Act
boolean exists = userRepository.existsByEmail("doesnotexist@example.com");

// Assert
assertThat(exists).isFalse();
}
}

Key Patterns:

  • Use @DataJpaTest for repository tests
  • Use @ActiveProfiles("test") to use test profile (H2 database)
  • Use TestEntityManager to set up test data
  • Flush after persist to ensure data is in database
  • Test both positive and negative scenarios

Step 5: Create Integration Tests

File: server/src/test/java/com/saas/springular/user/api/AuthenticationControllerIT.java (NEW)

Purpose: Integration test for complete authentication flow.

Create test class:

package com.saas.springular.user.api;

import com.saas.springular.test.TestDataBuilder;
import com.saas.springular.user.domain.User;
import com.saas.springular.user.domain.UserRepository;
import io.restassured.RestAssured;
import io.restassured.http.ContentType;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.context.ActiveProfiles;

import static io.restassured.RestAssured.*;
import static org.hamcrest.Matchers.*;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
@DisplayName("Authentication Integration Tests")
class AuthenticationControllerIT {

@LocalServerPort
private int port;

@Autowired
private UserRepository userRepository;

@Autowired
private PasswordEncoder passwordEncoder;

@BeforeEach
void setUp() {
RestAssured.port = port;
RestAssured.basePath = "/api";
userRepository.deleteAll();
}

@Test
@DisplayName("POST /auth/login - Valid credentials returns JWT tokens")
void login_ValidCredentials_ReturnsJwtTokens() {
// Arrange - Create test user
User user = TestDataBuilder.user()
.email("integration-test@example.com")
.password(passwordEncoder.encode("Test123!@#"))
.build();
userRepository.save(user);

// Act & Assert
given()
.contentType(ContentType.JSON)
.body("""
{
"email": "integration-test@example.com",
"password": "Test123!@#"
}
""")
.when()
.post("/auth/login")
.then()
.statusCode(200)
.body("accessToken", notNullValue())
.body("refreshToken", notNullValue())
.body("tokenType", equalTo("Bearer"));
}

@Test
@DisplayName("POST /auth/login - Invalid credentials returns 401")
void login_InvalidCredentials_ReturnsUnauthorized() {
given()
.contentType(ContentType.JSON)
.body("""
{
"email": "nonexistent@example.com",
"password": "WrongPassword"
}
""")
.when()
.post("/auth/login")
.then()
.statusCode(401);
}

@Test
@DisplayName("POST /auth/register - Valid request creates user")
void register_ValidRequest_CreatesUser() {
given()
.contentType(ContentType.JSON)
.body("""
{
"email": "newuser@example.com",
"password": "NewPass123!@#",
"firstName": "New",
"lastName": "User"
}
""")
.when()
.post("/auth/register")
.then()
.statusCode(201);

// Verify user was created
assertThat(userRepository.existsByEmail("newuser@example.com")).isTrue();
}

@Test
@DisplayName("POST /auth/register - Duplicate email returns 400")
void register_DuplicateEmail_ReturnsBadRequest() {
// Arrange - Create existing user
User existing = TestDataBuilder.user()
.email("existing@example.com")
.build();
userRepository.save(existing);

// Act & Assert
given()
.contentType(ContentType.JSON)
.body("""
{
"email": "existing@example.com",
"password": "Pass123!@#",
"firstName": "Duplicate",
"lastName": "User"
}
""")
.when()
.post("/auth/register")
.then()
.statusCode(400)
.body("message", containsString("Email already in use"));
}
}

Key Patterns:

  • Use @SpringBootTest with RANDOM_PORT for full integration tests
  • Use RestAssured for API testing (cleaner than MockMvc)
  • Use @BeforeEach to configure RestAssured and clean database
  • Test complete flows (request → controller → service → repository → response)
  • Verify side effects (database changes)

Step 6: Create Frontend Component Tests

File: client/src/app/pages/auth/login/login.component.spec.ts

Purpose: Replace stub with real component tests.

Update test file:

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ReactiveFormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { of, throwError } from 'rxjs';
import { LoginComponent } from './login.component';
import { AuthService } from '@app/core/services/auth.service';

describe('LoginComponent', () => {
let component: LoginComponent;
let fixture: ComponentFixture<LoginComponent>;
let authService: jasmine.SpyObj<AuthService>;
let router: jasmine.SpyObj<Router>;

beforeEach(async () => {
const authServiceSpy = jasmine.createSpyObj('AuthService', ['login']);
const routerSpy = jasmine.createSpyObj('Router', ['navigate']);

await TestBed.configureTestingModule({
declarations: [LoginComponent],
imports: [ReactiveFormsModule],
providers: [
{ provide: AuthService, useValue: authServiceSpy },
{ provide: Router, useValue: routerSpy }
]
}).compileComponents();

authService = TestBed.inject(AuthService) as jasmine.SpyObj<AuthService>;
router = TestBed.inject(Router) as jasmine.SpyObj<Router>;
});

beforeEach(() => {
fixture = TestBed.createComponent(LoginComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

it('should create', () => {
expect(component).toBeTruthy();
});

it('should have invalid form when empty', () => {
expect(component.loginForm.valid).toBeFalsy();
});

it('should have invalid form when email is invalid', () => {
component.loginForm.patchValue({
email: 'invalid-email',
password: 'Test123!@#'
});
expect(component.loginForm.valid).toBeFalsy();
});

it('should have valid form when all fields are valid', () => {
component.loginForm.patchValue({
email: 'test@example.com',
password: 'Test123!@#'
});
expect(component.loginForm.valid).toBeTruthy();
});

it('should call authService.login on submit with valid form', () => {
const mockResponse = { accessToken: 'token123', refreshToken: 'refresh456' };
authService.login.and.returnValue(of(mockResponse));

component.loginForm.patchValue({
email: 'test@example.com',
password: 'Test123!@#'
});

component.onSubmit();

expect(authService.login).toHaveBeenCalledWith('test@example.com', 'Test123!@#');
});

it('should navigate to dashboard on successful login', () => {
const mockResponse = { accessToken: 'token123', refreshToken: 'refresh456' };
authService.login.and.returnValue(of(mockResponse));

component.loginForm.patchValue({
email: 'test@example.com',
password: 'Test123!@#'
});

component.onSubmit();

expect(router.navigate).toHaveBeenCalledWith(['/dashboard']);
});

it('should display error message on login failure', () => {
authService.login.and.returnValue(
throwError(() => ({ error: { message: 'Invalid credentials' } }))
);

component.loginForm.patchValue({
email: 'test@example.com',
password: 'WrongPassword'
});

component.onSubmit();

expect(component.errorMessage).toBe('Invalid credentials');
});
});

Key Patterns:

  • Use Jasmine spies for service mocks
  • Test form validation
  • Test component behavior (not UI rendering)
  • Test successful and error scenarios
  • Use of() and throwError() for RxJS mock responses

Verification

Step 1: Run Backend Tests

Run all backend tests:

cd server
./gradlew test

Expected Result:

  • All tests pass
  • Test report generated in build/reports/tests/test/index.html

Run with coverage:

./gradlew test jacocoTestReport

Check coverage report: build/reports/jacoco/test/html/index.html


Step 2: Run Frontend Tests

Run all frontend tests:

cd client
npm test

Expected Result:

  • All tests pass
  • Coverage report generated

Run with coverage:

npm test -- --code-coverage

Check coverage report: coverage/index.html


Step 3: Verify Coverage Goals

Target Coverage (for critical paths):

  • Authentication: >80%
  • Payments (Stripe): >70%
  • User Management: >70%
  • Security-critical code (JWT, CORS, CSRF): 100%

Check coverage reports and identify gaps.


Testing Best Practices

Test Naming Convention

Use: methodName_Scenario_ExpectedBehavior

Good Examples:

  • login_ValidCredentials_ReturnsJwtTokens
  • register_DuplicateEmail_ThrowsException
  • findByEmail_NonExistingUser_ReturnsEmpty

Bad Examples:

  • testLogin (no scenario or expected behavior)
  • test1 (meaningless)
  • shouldWork (vague)

Test Organization

AAA Pattern (Arrange-Act-Assert):

@Test
void methodName_Scenario_ExpectedBehavior() {
// Arrange - Set up test data and mocks
User user = TestDataBuilder.user().build();
when(repository.findById(1L)).thenReturn(Optional.of(user));

// Act - Execute the method under test
User result = service.getUserById(1L);

// Assert - Verify the results
assertThat(result).isNotNull();
assertThat(result.getId()).isEqualTo(1L);
}

Mock vs Real Database

Use Mocks (Unit Tests):

  • Service layer tests
  • Controller tests (when testing controller logic only)
  • Fast execution
  • Isolated from external dependencies

Use Real Database (Integration Tests):

  • Repository tests
  • Full integration tests (controller → service → repository)
  • Tests database queries work correctly
  • Tests database constraints work

Troubleshooting

Tests Fail with "No qualifying bean"

Issue: Spring context not loading properly.

Solution:

  1. Add @SpringBootTest to integration tests
  2. Add @DataJpaTest to repository tests
  3. Add @ActiveProfiles("test") to use test profile

H2 Database Schema Issues

Issue: Tests fail with schema errors.

Solution:

  1. Verify test profile has flyway.enabled: false
  2. Verify test profile has hibernate.ddl-auto: create-drop
  3. Check entity annotations are correct

RestAssured Port Issues

Issue: RestAssured connects to wrong port.

Solution:

  1. Use @LocalServerPort to inject random port
  2. Set RestAssured.port = port in @BeforeEach

Next Steps

After completing this guide:

  1. API Contract & Documentation - OpenAPI specs, Swagger UI, contract tests

Summary

This implementation provides:

  • Unit Tests: Service layer with mocks
  • Repository Tests: Database queries with H2
  • Integration Tests: Complete flows (API → DB)
  • Frontend Tests: Component behavior and validation
  • Test Data Builders: Reusable test fixtures
  • Coverage Goals: >70% for critical paths

Test coverage is critical for validating security and reliability fixes work correctly and preventing future regressions.