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àoassertThat()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: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 }) => {
// 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:8080Test 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ì | Tool | Khi nào dùng |
|---|---|---|
| Service business logic | JUnit + Mockito | Luôn luôn — feedback nhanh nhất |
| Controller routes, status codes | @WebMvcTest | Khi verify HTTP behavior |
| JPA queries, entity mappings | @DataJpaTest | Khi viết 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 | Mọ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.