Back to blog

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

javaspring-bootreactnextjsvideo-streaming
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 in
  • assertThat() 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:3000

Playwright E2E Tests

Setup

cd web
npm install -D @playwright/test
npx playwright install chromium

Playwright 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:8080

Test 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 TestToolWhen to Use
Service business logicJUnit + MockitoAlways — fastest feedback
Controller routes, status codes@WebMvcTestWhen verifying HTTP behavior
JPA queries, entity mappings@DataJpaTestWhen writing custom queries
Full request flowTestcontainersCritical paths (auth, payment)
Stripe webhook signaturesHMAC mockVerify signature validation
User-visible flowsPlaywrightSignup, checkout, video playback
Security rules@WithMockUserEvery 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.