Build a Video Platform: Testing Strategy (Unit, Integration, E2E)

We've built 13 phases of a video platform — authentication, streaming, payments, analytics, security. But how do we know it actually works? Manual testing doesn't scale, and "it works on my machine" doesn't survive deployment. In this post, we'll build a testing strategy that covers every layer: unit tests for business logic, integration tests with real databases, API tests with Spring Boot slices, and end-to-end tests with Playwright.
The goal isn't 100% coverage — it's confidence. We want to catch regressions before they hit production and verify that critical flows (signup, payment, video playback) work correctly.
Time commitment: 4–5 hours
Prerequisites: Phase 12: Security & Performance Hardening
What we'll build in this post:
✅ Unit tests with JUnit 5 + Mockito for services
✅ Spring Boot test slices (@WebMvcTest, @DataJpaTest)
✅ Integration tests with Testcontainers (Postgres + Redis)
✅ Stripe webhook testing with mock signatures
✅ Playwright E2E tests for critical user flows
✅ CI-friendly test configuration
Testing Pyramid
- Unit tests: Fast, isolated, test business logic. Mock all dependencies.
- Integration tests: Test with real Postgres/Redis via Testcontainers. Verify queries, caching, transactions.
- E2E tests: Test full user flows through the browser. Catch UI regressions and broken interactions.
Test Dependencies
<!-- pom.xml -->
<dependencies>
<!-- Test framework -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Testcontainers -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>1.19.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<version>1.19.3</version>
<scope>test</scope>
</dependency>
<!-- Security test -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>Unit Tests: Service Layer
SubscriptionService Test
// src/test/java/com/videoplatform/api/service/SubscriptionServiceTest.java
package com.videoplatform.api.service;
import com.videoplatform.api.entity.*;
import com.videoplatform.api.repository.SubscriptionRepository;
import org.junit.jupiter.api.BeforeEach;
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 java.time.LocalDateTime;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class SubscriptionServiceTest {
@Mock
private SubscriptionRepository subscriptionRepository;
@InjectMocks
private SubscriptionService subscriptionService;
private User testUser;
@BeforeEach
void setUp() {
testUser = new User();
testUser.setId(1L);
testUser.setEmail("test@example.com");
}
@Test
void hasActiveSubscription_withActiveSubscription_returnsTrue() {
when(subscriptionRepository.existsByUserEmailAndStatus(
"test@example.com", SubscriptionStatus.ACTIVE))
.thenReturn(true);
boolean result = subscriptionService.hasActiveSubscription("test@example.com");
assertThat(result).isTrue();
}
@Test
void hasActiveSubscription_withNoSubscription_returnsFalse() {
when(subscriptionRepository.existsByUserEmailAndStatus(
"test@example.com", SubscriptionStatus.ACTIVE))
.thenReturn(false);
boolean result = subscriptionService.hasActiveSubscription("test@example.com");
assertThat(result).isFalse();
}
@Test
void hasActiveSubscription_withExpiredSubscription_returnsFalse() {
when(subscriptionRepository.existsByUserEmailAndStatus(
"test@example.com", SubscriptionStatus.ACTIVE))
.thenReturn(false);
boolean result = subscriptionService.hasActiveSubscription("test@example.com");
assertThat(result).isFalse();
}
@Test
void isInGracePeriod_withPastDueSubscription_returnsTrue() {
Subscription pastDue = new Subscription();
pastDue.setStatus(SubscriptionStatus.PAST_DUE);
pastDue.setCurrentPeriodEnd(LocalDateTime.now().plusDays(5));
when(subscriptionRepository.findByUserEmailAndStatus(
"test@example.com", SubscriptionStatus.PAST_DUE))
.thenReturn(Optional.of(pastDue));
boolean result = subscriptionService.isInGracePeriod("test@example.com");
assertThat(result).isTrue();
}
@Test
void isInGracePeriod_withExpiredGracePeriod_returnsFalse() {
Subscription pastDue = new Subscription();
pastDue.setStatus(SubscriptionStatus.PAST_DUE);
pastDue.setCurrentPeriodEnd(LocalDateTime.now().minusDays(1));
when(subscriptionRepository.findByUserEmailAndStatus(
"test@example.com", SubscriptionStatus.PAST_DUE))
.thenReturn(Optional.of(pastDue));
boolean result = subscriptionService.isInGracePeriod("test@example.com");
assertThat(result).isFalse();
}
}AnalyticsService Test
// src/test/java/com/videoplatform/api/service/AnalyticsServiceTest.java
package com.videoplatform.api.service;
import com.videoplatform.api.dto.response.DashboardMetrics;
import com.videoplatform.api.entity.SubscriptionStatus;
import com.videoplatform.api.repository.*;
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 java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class AnalyticsServiceTest {
@Mock private UserRepository userRepository;
@Mock private SubscriptionRepository subscriptionRepository;
@Mock private CourseRepository courseRepository;
@Mock private LessonRepository lessonRepository;
@Mock private PaymentHistoryRepository paymentRepository;
@Mock private ProgressRepository progressRepository;
@InjectMocks
private AnalyticsService analyticsService;
@Test
void getDashboardMetrics_returnsCorrectCounts() {
when(userRepository.count()).thenReturn(100L);
when(subscriptionRepository.countByStatus(SubscriptionStatus.ACTIVE)).thenReturn(42L);
when(courseRepository.count()).thenReturn(5L);
when(lessonRepository.count()).thenReturn(50L);
when(paymentRepository.sumAmountByStatusAndCreatedAtAfter(eq("succeeded"), any()))
.thenReturn(Optional.of(99900L)); // $999.00 in cents
when(paymentRepository.sumAmountByStatus("succeeded"))
.thenReturn(Optional.of(500000L)); // $5000.00
when(progressRepository.averageCompletionRate())
.thenReturn(Optional.of(67.5));
when(subscriptionRepository.countByCreatedAtAfter(any())).thenReturn(8L);
when(subscriptionRepository.countByStatusAndUpdatedAtAfter(
eq(SubscriptionStatus.EXPIRED), any())).thenReturn(2L);
DashboardMetrics metrics = analyticsService.getDashboardMetrics();
assertThat(metrics.totalUsers()).isEqualTo(100);
assertThat(metrics.activeSubscribers()).isEqualTo(42);
assertThat(metrics.totalCourses()).isEqualTo(5);
assertThat(metrics.monthlyRevenue()).isEqualByComparingTo("999.00");
assertThat(metrics.totalRevenue()).isEqualByComparingTo("5000.00");
assertThat(metrics.averageCompletionRate()).isEqualTo(67.5);
assertThat(metrics.newSubscribersThisMonth()).isEqualTo(8);
assertThat(metrics.churnedThisMonth()).isEqualTo(2);
}
@Test
void getDashboardMetrics_withNoPayments_returnsZeroRevenue() {
when(userRepository.count()).thenReturn(0L);
when(subscriptionRepository.countByStatus(any())).thenReturn(0L);
when(courseRepository.count()).thenReturn(0L);
when(lessonRepository.count()).thenReturn(0L);
when(paymentRepository.sumAmountByStatusAndCreatedAtAfter(any(), any()))
.thenReturn(Optional.empty());
when(paymentRepository.sumAmountByStatus(any()))
.thenReturn(Optional.empty());
when(progressRepository.averageCompletionRate())
.thenReturn(Optional.empty());
when(subscriptionRepository.countByCreatedAtAfter(any())).thenReturn(0L);
when(subscriptionRepository.countByStatusAndUpdatedAtAfter(any(), any())).thenReturn(0L);
DashboardMetrics metrics = analyticsService.getDashboardMetrics();
assertThat(metrics.monthlyRevenue()).isEqualByComparingTo("0.00");
assertThat(metrics.totalRevenue()).isEqualByComparingTo("0.00");
assertThat(metrics.averageCompletionRate()).isEqualTo(0.0);
}
}Key patterns:
@ExtendWith(MockitoExtension.class)— no Spring context needed, tests run in milliseconds@Mock+@InjectMocks— Mockito creates fakes and wires them inassertThat()from AssertJ — readable, fluent assertions- Each test verifies one behavior — name says what it tests
API Tests: Spring Boot Test Slices
@WebMvcTest for Controllers
// src/test/java/com/videoplatform/api/controller/PublicCourseControllerTest.java
package com.videoplatform.api.controller;
import com.videoplatform.api.dto.response.PublicCourseListResponse;
import com.videoplatform.api.service.PublicCourseService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.bean.MockBean;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(PublicCourseController.class)
class PublicCourseControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private PublicCourseService courseService;
@Test
void listCourses_returnsPublishedCourses() throws Exception {
var courses = List.of(
new PublicCourseListResponse(1L, "java-basics", "Java Basics",
"Learn Java", "/img/java.png", "BEGINNER", 10, 3600)
);
when(courseService.getAllPublishedCourses()).thenReturn(courses);
mockMvc.perform(get("/api/public/courses"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data").isArray())
.andExpect(jsonPath("$.data[0].slug").value("java-basics"))
.andExpect(jsonPath("$.data[0].title").value("Java Basics"));
}
@Test
void getCourse_withInvalidSlug_returns404() throws Exception {
when(courseService.getCourseBySlug("nonexistent"))
.thenThrow(new com.videoplatform.api.exception
.ResourceNotFoundException("Course not found"));
mockMvc.perform(get("/api/public/courses/nonexistent"))
.andExpect(status().isNotFound());
}
}Admin Endpoint Security Test
// src/test/java/com/videoplatform/api/controller/AdminAnalyticsControllerTest.java
package com.videoplatform.api.controller;
import com.videoplatform.api.service.AnalyticsService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.bean.MockBean;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(AdminAnalyticsController.class)
class AdminAnalyticsControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private AnalyticsService analyticsService;
@Test
void dashboard_withoutAuth_returns401() throws Exception {
mockMvc.perform(get("/api/admin/analytics/dashboard"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(roles = "USER")
void dashboard_withUserRole_returns403() throws Exception {
mockMvc.perform(get("/api/admin/analytics/dashboard"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(roles = "ADMIN")
void dashboard_withAdminRole_returns200() throws Exception {
mockMvc.perform(get("/api/admin/analytics/dashboard"))
.andExpect(status().isOk());
}
}@WebMvcTest loads only the web layer — no database, no Redis, no Stripe. Tests verify HTTP status codes, JSON structure, and security rules. @WithMockUser simulates different authentication states without a real JWT.
@DataJpaTest for Repositories
// src/test/java/com/videoplatform/api/repository/SubscriptionRepositoryTest.java
package com.videoplatform.api.repository;
import com.videoplatform.api.entity.*;
import org.junit.jupiter.api.BeforeEach;
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 java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest
class SubscriptionRepositoryTest {
@Autowired
private TestEntityManager entityManager;
@Autowired
private SubscriptionRepository subscriptionRepository;
private User testUser;
@BeforeEach
void setUp() {
testUser = new User();
testUser.setEmail("test@example.com");
testUser.setPasswordHash("hashed");
testUser.setName("Test User");
testUser.setRole(Role.USER);
testUser = entityManager.persistAndFlush(testUser);
}
@Test
void countByStatus_returnsCorrectCount() {
createSubscription(SubscriptionStatus.ACTIVE);
createSubscription(SubscriptionStatus.ACTIVE);
createSubscription(SubscriptionStatus.EXPIRED);
long activeCount = subscriptionRepository.countByStatus(SubscriptionStatus.ACTIVE);
assertThat(activeCount).isEqualTo(2);
}
@Test
void existsByUserEmailAndStatus_withActiveSubscription_returnsTrue() {
createSubscription(SubscriptionStatus.ACTIVE);
boolean exists = subscriptionRepository.existsByUserEmailAndStatus(
"test@example.com", SubscriptionStatus.ACTIVE);
assertThat(exists).isTrue();
}
@Test
void existsByUserEmailAndStatus_withNoSubscription_returnsFalse() {
boolean exists = subscriptionRepository.existsByUserEmailAndStatus(
"test@example.com", SubscriptionStatus.ACTIVE);
assertThat(exists).isFalse();
}
private Subscription createSubscription(SubscriptionStatus status) {
Subscription sub = new Subscription();
sub.setUser(testUser);
sub.setStripeSubscriptionId("sub_" + System.nanoTime());
sub.setStripeCustomerId("cus_test");
sub.setPlanType(PlanType.MONTHLY);
sub.setStatus(status);
sub.setCurrentPeriodStart(LocalDateTime.now());
sub.setCurrentPeriodEnd(LocalDateTime.now().plusDays(30));
return entityManager.persistAndFlush(sub);
}
}@DataJpaTest spins up an in-memory H2 database and auto-configures JPA. It verifies that your JPQL queries, derived queries, and entity mappings work correctly — without needing Testcontainers for fast feedback during development.
Integration Tests: Testcontainers
Base Test Class
// src/test/java/com/videoplatform/api/IntegrationTestBase.java
package com.videoplatform.api;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
public abstract class IntegrationTestBase {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("videoplatform_test")
.withUsername("test")
.withPassword("test");
@Container
static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379);
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
registry.add("spring.data.redis.host", redis::getHost);
registry.add("spring.data.redis.port", () -> redis.getMappedPort(6379));
registry.add("spring.jpa.hibernate.ddl-auto", () -> "create-drop");
}
}Testcontainers starts real Postgres and Redis instances in Docker. @DynamicPropertySource injects the random connection URLs into Spring Boot's configuration. Tests run against the same database engine as production — no more "works with H2 but fails with Postgres" surprises.
Full Flow Integration Test
// src/test/java/com/videoplatform/api/integration/CourseFlowIntegrationTest.java
package com.videoplatform.api.integration;
import com.videoplatform.api.IntegrationTestBase;
import com.videoplatform.api.dto.request.LoginRequest;
import com.videoplatform.api.dto.request.RegisterRequest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.*;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class CourseFlowIntegrationTest extends IntegrationTestBase {
@Autowired
private TestRestTemplate restTemplate;
@Test
void fullCourseFlow_registerLoginAndBrowseCourses() {
// 1. Register
var registerRequest = new RegisterRequest(
"flow@test.com", "password123", "Flow User");
ResponseEntity<Map> registerResponse = restTemplate.postForEntity(
"/api/auth/register", registerRequest, Map.class);
assertThat(registerResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
// 2. Login
var loginRequest = new LoginRequest("flow@test.com", "password123");
ResponseEntity<Map> loginResponse = restTemplate.postForEntity(
"/api/auth/login", loginRequest, Map.class);
assertThat(loginResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
Map<String, Object> loginData = (Map<String, Object>) loginResponse.getBody().get("data");
String token = (String) loginData.get("accessToken");
// 3. Browse courses (public — no auth needed)
ResponseEntity<Map> coursesResponse = restTemplate.getForEntity(
"/api/public/courses", Map.class);
assertThat(coursesResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
// 4. Access protected endpoint with token
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(token);
ResponseEntity<Map> profileResponse = restTemplate.exchange(
"/api/users/me", HttpMethod.GET,
new HttpEntity<>(headers), Map.class);
assertThat(profileResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
void register_withDuplicateEmail_returns409() {
var request = new RegisterRequest(
"duplicate@test.com", "password123", "User 1");
restTemplate.postForEntity("/api/auth/register", request, Map.class);
// Try registering again with same email
ResponseEntity<Map> response = restTemplate.postForEntity(
"/api/auth/register", request, Map.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT);
}
}Subscription Integration Test
// src/test/java/com/videoplatform/api/integration/SubscriptionIntegrationTest.java
package com.videoplatform.api.integration;
import com.videoplatform.api.IntegrationTestBase;
import com.videoplatform.api.entity.*;
import com.videoplatform.api.repository.SubscriptionRepository;
import com.videoplatform.api.repository.UserRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.*;
import java.time.LocalDateTime;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class SubscriptionIntegrationTest extends IntegrationTestBase {
@Autowired private TestRestTemplate restTemplate;
@Autowired private UserRepository userRepository;
@Autowired private SubscriptionRepository subscriptionRepository;
private String adminToken;
@BeforeEach
void setUp() {
// Create admin user and get token
// ... (register + login as admin)
}
@Test
void grantSubscription_createsActiveSubscription() {
// Create a regular user
User user = new User();
user.setEmail("grant-test@example.com");
user.setPasswordHash("hashed");
user.setName("Grant Test");
user.setRole(Role.USER);
user = userRepository.save(user);
// Grant subscription via admin API
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(adminToken);
headers.setContentType(MediaType.APPLICATION_JSON);
Map<String, Object> grantRequest = Map.of(
"durationDays", 30,
"reason", "Integration test");
ResponseEntity<Map> response = restTemplate.exchange(
"/api/admin/users/" + user.getId() + "/grant-subscription",
HttpMethod.POST,
new HttpEntity<>(grantRequest, headers),
Map.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
// Verify subscription was created
var subscription = subscriptionRepository
.findByUserAndStatus(user, SubscriptionStatus.ACTIVE);
assertThat(subscription).isPresent();
assertThat(subscription.get().getCurrentPeriodEnd())
.isAfter(LocalDateTime.now().plusDays(29));
}
}Stripe Webhook Testing
Mock Stripe Signature
// src/test/java/com/videoplatform/api/webhook/StripeWebhookTest.java
package com.videoplatform.api.webhook;
import com.videoplatform.api.IntegrationTestBase;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.*;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
class StripeWebhookTest extends IntegrationTestBase {
@Autowired
private TestRestTemplate restTemplate;
@Value("${stripe.webhook-secret}")
private String webhookSecret;
@Test
void webhook_withValidSignature_returns200() throws Exception {
String payload = """
{
"id": "evt_test_123",
"type": "checkout.session.completed",
"data": {
"object": {
"id": "cs_test_123",
"customer": "cus_test_123",
"subscription": "sub_test_123",
"metadata": {
"userId": "1"
}
}
}
}
""";
String signature = generateStripeSignature(payload, webhookSecret);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("Stripe-Signature", signature);
ResponseEntity<String> response = restTemplate.exchange(
"/api/webhooks/stripe",
HttpMethod.POST,
new HttpEntity<>(payload, headers),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
void webhook_withInvalidSignature_returns400() {
String payload = "{\"type\": \"test\"}";
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("Stripe-Signature", "t=123,v1=invalid_signature");
ResponseEntity<String> response = restTemplate.exchange(
"/api/webhooks/stripe",
HttpMethod.POST,
new HttpEntity<>(payload, headers),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
void webhook_withoutSignature_returns400() {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
ResponseEntity<String> response = restTemplate.exchange(
"/api/webhooks/stripe",
HttpMethod.POST,
new HttpEntity<>("{}", headers),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
}
private String generateStripeSignature(String payload, String secret) throws Exception {
long timestamp = Instant.now().getEpochSecond();
String signedPayload = timestamp + "." + payload;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] hash = mac.doFinal(signedPayload.getBytes(StandardCharsets.UTF_8));
String signature = bytesToHex(hash);
return "t=" + timestamp + ",v1=" + signature;
}
private static String bytesToHex(byte[] bytes) {
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
}The generateStripeSignature method replicates Stripe's webhook signing algorithm (HMAC-SHA256). This lets us test webhook handling without hitting Stripe's API — the signature verification in StripeWebhookController works exactly the same way.
Test Application Properties
# src/test/resources/application-test.yml
stripe:
secret-key: sk_test_fake
webhook-secret: whsec_test_secret_for_testing
prices:
monthly: price_test_monthly
yearly: price_test_yearly
spring:
mail:
host: localhost
port: 1025 # MailHog or similar
app:
base-url: http://localhost
cors:
allowed-origins:
- http://localhost:3000Playwright E2E Tests
Setup
cd web
npm install -D @playwright/test
npx playwright install chromiumPlaywright Config
// web/playwright.config.ts
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
timeout: 30000,
retries: 1,
use: {
baseURL: "http://localhost:3000",
screenshot: "only-on-failure",
trace: "retain-on-failure",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
webServer: {
command: "npm run dev",
port: 3000,
reuseExistingServer: true,
},
});Auth Flow E2E Test
// web/e2e/auth.spec.ts
import { test, expect } from "@playwright/test";
test.describe("Authentication", () => {
test("user can register and login", async ({ page }) => {
// Go to register page
await page.goto("/register");
// Fill registration form
await page.fill('[name="email"]', `e2e-${Date.now()}@test.com`);
await page.fill('[name="password"]', "TestPassword123!");
await page.fill('[name="name"]', "E2E Test User");
await page.click('button[type="submit"]');
// Should redirect to login or dashboard
await expect(page).toHaveURL(/\/(login|dashboard)/);
});
test("login with wrong password shows error", async ({ page }) => {
await page.goto("/login");
await page.fill('[name="email"]', "wrong@test.com");
await page.fill('[name="password"]', "wrongpassword");
await page.click('button[type="submit"]');
// Should show error message
await expect(page.locator('[role="alert"]')).toBeVisible();
});
});Course Catalog E2E Test
// web/e2e/catalog.spec.ts
import { test, expect } from "@playwright/test";
test.describe("Course Catalog", () => {
test("catalog page loads and shows courses", async ({ page }) => {
await page.goto("/courses");
// Page should have a heading
await expect(page.locator("h1")).toContainText("Course");
// Should show course cards (if courses exist)
const cards = page.locator('[data-testid="course-card"]');
// At minimum, the page should load without errors
await expect(page).not.toHaveTitle(/error|500/i);
});
test("course detail page shows syllabus", async ({ page }) => {
// Navigate to a known course
await page.goto("/courses/java-basics");
// Should show course title
await expect(page.locator("h1")).toBeVisible();
// Should show sections
const sections = page.locator('[data-testid="course-section"]');
await expect(sections.first()).toBeVisible();
});
test("free preview lesson is accessible without login", async ({ page }) => {
await page.goto("/courses/java-basics");
// Click a free preview lesson
const freeLesson = page.locator('text=Free Preview').first();
if (await freeLesson.isVisible()) {
await freeLesson.click();
// Video player should load
await expect(page.locator("video")).toBeVisible({ timeout: 10000 });
}
});
});Subscription Flow E2E Test
// web/e2e/subscription.spec.ts
import { test, expect } from "@playwright/test";
test.describe("Subscription", () => {
test.beforeEach(async ({ page }) => {
// Login as test user
await page.goto("/login");
await page.fill('[name="email"]', "e2e@test.com");
await page.fill('[name="password"]', "TestPassword123!");
await page.click('button[type="submit"]');
await page.waitForURL(/dashboard|courses/);
});
test("pricing page shows plans", async ({ page }) => {
await page.goto("/pricing");
// Should show monthly and yearly options
await expect(page.locator("text=Monthly")).toBeVisible();
await expect(page.locator("text=Yearly")).toBeVisible();
});
test("subscribe button redirects to Stripe Checkout", async ({ page }) => {
await page.goto("/pricing");
// Click subscribe on monthly plan
const subscribeButton = page.locator('button:has-text("Subscribe")').first();
await subscribeButton.click();
// Should redirect to Stripe Checkout (external URL)
await page.waitForURL(/checkout\.stripe\.com/, { timeout: 10000 });
});
});CI Configuration
GitHub Actions Workflow
# .github/workflows/test.yml
name: Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
backend-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: "17"
distribution: "temurin"
- name: Cache Maven dependencies
uses: actions/cache@v4
with:
path: ~/.m2
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
- name: Run unit tests
run: mvn test -pl api -Dtest="*Test" -DfailIfNoTests=false
- name: Run integration tests
run: mvn test -pl api -Dtest="*IntegrationTest,*WebhookTest" -DfailIfNoTests=false
frontend-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
cache-dependency-path: web/package-lock.json
- name: Install dependencies
run: cd web && npm ci
- name: Run linter
run: cd web && npm run lint
- name: Install Playwright
run: cd web && npx playwright install --with-deps chromium
- name: Run E2E tests
run: cd web && npx playwright test
env:
NEXT_PUBLIC_API_URL: http://localhost:8080Test Script in package.json
{
"scripts": {
"test": "jest",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:headed": "playwright test --headed"
}
}Testing Cheat Sheet
| What to Test | Tool | When to Use |
|---|---|---|
| Service business logic | JUnit + Mockito | Always — fastest feedback |
| Controller routes, status codes | @WebMvcTest | When verifying HTTP behavior |
| JPA queries, entity mappings | @DataJpaTest | When writing custom queries |
| Full request flow | Testcontainers | Critical paths (auth, payment) |
| Stripe webhook signatures | HMAC mock | Verify signature validation |
| User-visible flows | Playwright | Signup, checkout, video playback |
| Security rules | @WithMockUser | Every admin/protected endpoint |
Common Mistakes
1. Testing Implementation Instead of Behavior
// WRONG — coupled to implementation details
verify(subscriptionRepository, times(1)).save(any());
verify(eventPublisher, times(1)).publishEvent(any());
// RIGHT — verify the observable outcome
assertThat(subscriptionService.hasActiveSubscription("test@test.com")).isTrue();2. Sharing State Between Tests
// WRONG — test order matters, flaky tests
static User sharedUser; // One test creates, another reads
// RIGHT — each test creates its own data
@BeforeEach
void setUp() {
testUser = createTestUser("test-" + UUID.randomUUID() + "@test.com");
}3. Not Using Testcontainers for DB Tests
// WRONG — H2 and Postgres have different SQL dialects
@DataJpaTest // Uses H2 by default
class PaymentRepositoryTest {
// SUM(amount) might work differently in H2 vs Postgres
}
// RIGHT — use Testcontainers for query-heavy tests
@SpringBootTest
@Testcontainers
class PaymentRepositoryTest extends IntegrationTestBase {
// Same Postgres engine as production
}4. Flaky E2E Tests from Timing Issues
// WRONG — arbitrary wait
await page.click("button");
await page.waitForTimeout(3000);
expect(await page.textContent("h1")).toBe("Dashboard");
// RIGHT — wait for specific condition
await page.click("button");
await expect(page.locator("h1")).toContainText("Dashboard", { timeout: 5000 });What's Next?
The platform now has a comprehensive test suite — unit tests for logic, integration tests with real databases, webhook verification, and E2E tests for critical user flows. In Post #15 (the final post!), we'll deploy everything:
- Docker Compose production configuration
- Nginx SSL termination with Let's Encrypt
- Flyway production migrations
- Database backup strategy
- Automated deploy script
- Health monitoring with Spring Boot Actuator
Time to ship this to a real server.
Series: Build a Video Streaming Platform
Previous: Phase 12: Security & Performance Hardening
Next: Phase 14: Deployment & Production
📬 Subscribe to Newsletter
Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.
We respect your privacy. Unsubscribe at any time.
💬 Comments
Sign in to leave a comment
We'll never post without your permission.