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:
- Boilerplate Enhancements Solution Architecture - System design overview
- Boilerplate Enhancements Epic - Capability definition
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, AssertJspring-security-test- Security testing utilitiesh2- In-memory database for testsrest-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
@Mockfor dependencies - Use
@InjectMocksfor the class under test - Use
@BeforeEachfor test setup - Use
@DisplayNamefor 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
@DataJpaTestfor repository tests - Use
@ActiveProfiles("test")to use test profile (H2 database) - Use
TestEntityManagerto 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
@SpringBootTestwithRANDOM_PORTfor full integration tests - Use RestAssured for API testing (cleaner than MockMvc)
- Use
@BeforeEachto 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()andthrowError()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_ReturnsJwtTokensregister_DuplicateEmail_ThrowsExceptionfindByEmail_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:
- Add
@SpringBootTestto integration tests - Add
@DataJpaTestto repository tests - Add
@ActiveProfiles("test")to use test profile
H2 Database Schema Issues
Issue: Tests fail with schema errors.
Solution:
- Verify test profile has
flyway.enabled: false - Verify test profile has
hibernate.ddl-auto: create-drop - Check entity annotations are correct
RestAssured Port Issues
Issue: RestAssured connects to wrong port.
Solution:
- Use
@LocalServerPortto inject random port - Set
RestAssured.port = portin@BeforeEach
Next Steps
After completing this guide:
- ✅ 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.