Back to blog

Xây Dựng Video Platform: Testing Strategy (Unit, Integration, E2E)

javaspring-bootreactnextjsvideo-streaming
Xây Dựng Video Platform: Testing Strategy (Unit, Integration, E2E)

Ta đã build 13 phase của video platform — authentication, streaming, payments, analytics, security. Nhưng làm sao biết nó thực sự hoạt động? Manual testing không scale, và "chạy ngon trên máy tao" không survive qua deployment. Trong bài này, ta sẽ build testing strategy cover mọi layer: unit tests cho business logic, integration tests với real databases, API tests với Spring Boot slices, và end-to-end tests với Playwright.

Mục tiêu không phải 100% coverage — mà là confidence. Ta muốn bắt regressions trước khi chúng lên production và verify rằng các critical flows (signup, payment, video playback) hoạt động đúng.

Thời gian thực hiện: 4–5 giờ
Yêu cầu: Phase 12: Security & Performance Hardening

Những gì ta sẽ build trong bài này:
✅ Unit tests với JUnit 5 + Mockito cho services
✅ Spring Boot test slices (@WebMvcTest, @DataJpaTest)
✅ Integration tests với Testcontainers (Postgres + Redis)
✅ Stripe webhook testing với mock signatures
✅ Playwright E2E tests cho critical user flows
✅ CI-friendly test configuration


Testing Pyramid

  • Unit tests: Nhanh, isolated, test business logic. Mock tất cả dependencies.
  • Integration tests: Test với real Postgres/Redis qua Testcontainers. Verify queries, caching, transactions.
  • E2E tests: Test full user flows qua browser. Bắt UI regressions và 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("[email protected]");
    }
 
    @Test
    void hasActiveSubscription_withActiveSubscription_returnsTrue() {
        when(subscriptionRepository.existsByUserEmailAndStatus(
                "[email protected]", SubscriptionStatus.ACTIVE))
                .thenReturn(true);
 
        boolean result = subscriptionService.hasActiveSubscription("[email protected]");
 
        assertThat(result).isTrue();
    }
 
    @Test
    void hasActiveSubscription_withNoSubscription_returnsFalse() {
        when(subscriptionRepository.existsByUserEmailAndStatus(
                "[email protected]", SubscriptionStatus.ACTIVE))
                .thenReturn(false);
 
        boolean result = subscriptionService.hasActiveSubscription("[email protected]");
 
        assertThat(result).isFalse();
    }
 
    @Test
    void hasActiveSubscription_withExpiredSubscription_returnsFalse() {
        when(subscriptionRepository.existsByUserEmailAndStatus(
                "[email protected]", SubscriptionStatus.ACTIVE))
                .thenReturn(false);
 
        boolean result = subscriptionService.hasActiveSubscription("[email protected]");
 
        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(
                "[email protected]", SubscriptionStatus.PAST_DUE))
                .thenReturn(Optional.of(pastDue));
 
        boolean result = subscriptionService.isInGracePeriod("[email protected]");
 
        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(
                "[email protected]", SubscriptionStatus.PAST_DUE))
                .thenReturn(Optional.of(pastDue));
 
        boolean result = subscriptionService.isInGracePeriod("[email protected]");
 
        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 tính bằng 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);
    }
}

Các pattern chính:

  • @ExtendWith(MockitoExtension.class) — không cần Spring context, tests chạy trong milliseconds
  • @Mock + @InjectMocks — Mockito tạo fakes và wire chúng vào
  • assertThat() từ AssertJ — assertions dễ đọc, fluent
  • Mỗi test verify một behavior — tên test nói rõ đang test gì

API Tests: Spring Boot Test Slices

@WebMvcTest cho 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 chỉ load web layer — không database, không Redis, không Stripe. Tests verify HTTP status codes, JSON structure, và security rules. @WithMockUser giả lập các authentication states khác nhau mà không cần JWT thật.

@DataJpaTest cho 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("[email protected]");
        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(
                "[email protected]", SubscriptionStatus.ACTIVE);
 
        assertThat(exists).isTrue();
    }
 
    @Test
    void existsByUserEmailAndStatus_withNoSubscription_returnsFalse() {
        boolean exists = subscriptionRepository.existsByUserEmailAndStatus(
                "[email protected]", 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 spin up một in-memory H2 database và auto-configure JPA. Nó verify rằng JPQL queries, derived queries, và entity mappings hoạt động đúng — không cần Testcontainers để có feedback nhanh trong quá trình 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 start real Postgres và Redis instances trong Docker. @DynamicPropertySource inject các random connection URLs vào Spring Boot configuration. Tests chạy trên cùng database engine như production — không còn "chạy được với H2 nhưng fail với Postgres" nữa.

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(
                "[email protected]", "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("[email protected]", "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 — không cần auth)
        ResponseEntity<Map> coursesResponse = restTemplate.getForEntity(
                "/api/public/courses", Map.class);
 
        assertThat(coursesResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
 
        // 4. Truy cập protected endpoint với 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(
                "[email protected]", "password123", "User 1");
 
        restTemplate.postForEntity("/api/auth/register", request, Map.class);
 
        // Thử register lại với cùng 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() {
        // Tạo admin user và lấy token
        // ... (register + login as admin)
    }
 
    @Test
    void grantSubscription_createsActiveSubscription() {
        // Tạo regular user
        User user = new User();
        user.setEmail("[email protected]");
        user.setPasswordHash("hashed");
        user.setName("Grant Test");
        user.setRole(Role.USER);
        user = userRepository.save(user);
 
        // Grant subscription qua 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 đã được tạo
        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();
    }
}

Method generateStripeSignature replicate thuật toán signing webhook của Stripe (HMAC-SHA256). Điều này cho phép ta test webhook handling mà không cần hit Stripe API — signature verification trong StripeWebhookController hoạt động y hệt.

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 hoặc tương tự
 
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 }) => {
    // Vào trang register
    await page.goto("/register");
 
    // Điền form đăng ký
    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"]');
 
    // Phải redirect đến login hoặc 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"]', "[email protected]");
    await page.fill('[name="password"]', "wrongpassword");
    await page.click('button[type="submit"]');
 
    // Phải hiển thị 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 phải có heading
    await expect(page.locator("h1")).toContainText("Course");
 
    // Phải hiển thị course cards (nếu có courses)
    const cards = page.locator('[data-testid="course-card"]');
    // Tối thiểu, page phải load mà không lỗi
    await expect(page).not.toHaveTitle(/error|500/i);
  });
 
  test("course detail page shows syllabus", async ({ page }) => {
    // Navigate đến course đã biết
    await page.goto("/courses/java-basics");
 
    // Phải hiển thị course title
    await expect(page.locator("h1")).toBeVisible();
 
    // Phải hiển thị 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 vào free preview lesson
    const freeLesson = page.locator('text=Free Preview').first();
    if (await freeLesson.isVisible()) {
      await freeLesson.click();
 
      // Video player phải 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 với test user
    await page.goto("/login");
    await page.fill('[name="email"]', "[email protected]");
    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");
 
    // Phải hiển thị monthly và 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 trên monthly plan
    const subscribeButton = page.locator('button:has-text("Subscribe")').first();
    await subscribeButton.click();
 
    // Phải redirect đến 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 trong package.json

{
  "scripts": {
    "test": "jest",
    "test:e2e": "playwright test",
    "test:e2e:ui": "playwright test --ui",
    "test:e2e:headed": "playwright test --headed"
  }
}

Testing Cheat Sheet

Test gìToolKhi nào dùng
Service business logicJUnit + MockitoLuôn luôn — feedback nhanh nhất
Controller routes, status codes@WebMvcTestKhi verify HTTP behavior
JPA queries, entity mappings@DataJpaTestKhi viết custom queries
Full request flowTestcontainersCritical paths (auth, payment)
Stripe webhook signaturesHMAC mockVerify signature validation
User-visible flowsPlaywrightSignup, checkout, video playback
Security rules@WithMockUserMọi admin/protected endpoint

Những Lỗi Thường Gặp

1. Test Implementation Thay Vì Behavior

// SAI — coupled với implementation details
verify(subscriptionRepository, times(1)).save(any());
verify(eventPublisher, times(1)).publishEvent(any());
 
// ĐÚNG — verify observable outcome
assertThat(subscriptionService.hasActiveSubscription("[email protected]")).isTrue();

2. Chia Sẻ State Giữa Các Tests

// SAI — thứ tự test quan trọng, flaky tests
static User sharedUser; // Một test tạo, test khác đọc
 
// ĐÚNG — mỗi test tạo data riêng
@BeforeEach
void setUp() {
    testUser = createTestUser("test-" + UUID.randomUUID() + "@test.com");
}

3. Không Dùng Testcontainers Cho DB Tests

// SAI — H2 và Postgres có SQL dialects khác nhau
@DataJpaTest // Mặc định dùng H2
class PaymentRepositoryTest {
    // SUM(amount) có thể hoạt động khác nhau giữa H2 và Postgres
}
 
// ĐÚNG — dùng Testcontainers cho query-heavy tests
@SpringBootTest
@Testcontainers
class PaymentRepositoryTest extends IntegrationTestBase {
    // Cùng Postgres engine như production
}

4. E2E Tests Flaky Vì Timing Issues

// SAI — wait tùy tiện
await page.click("button");
await page.waitForTimeout(3000);
expect(await page.textContent("h1")).toBe("Dashboard");
 
// ĐÚNG — wait cho specific condition
await page.click("button");
await expect(page.locator("h1")).toContainText("Dashboard", { timeout: 5000 });

What's Next?

Platform giờ đã có comprehensive test suite — unit tests cho logic, integration tests với real databases, webhook verification, và E2E tests cho critical user flows. Ở Post #15 (bài cuối cùng!), ta sẽ deploy mọi thứ:

  • Docker Compose production configuration
  • Nginx SSL termination với Let's Encrypt
  • Flyway production migrations
  • Database backup strategy
  • Automated deploy script
  • Health monitoring với Spring Boot Actuator

Đã đến lúc ship lên server thật.

Series: Xây Dựng Video Streaming Platform
Trước đó: Phase 12: Security & Performance Hardening
Tiếp theo: Phase 14: Deployment & Production

📬 Đăng ký nhận bản tin

Nhận những bài viết mới nhất qua email mỗi tuần. Không spam, hủy đăng ký bất cứ lúc nào.

Chúng tôi tôn trọng quyền riêng tư của bạn. Hủy đăng ký bất cứ lúc nào.

💬 Comments

Đăng nhập để bình luận

Chúng tôi sẽ không đăng bất cứ điều gì mà không có sự cho phép của bạn.