Back to blog

Deep Dive: Testing & DevOps cho TypeScript

typescripttestingdevopsvitestci-cd
Deep Dive: Testing & DevOps cho TypeScript

Bạn đã xây dựng full stack — frontend, backend, database. Bây giờ đến phần phân biệt dự án cá nhân với phần mềm production: testing và deployment. Không có test, mọi thay đổi đều là canh bạc. Không có CI/CD, mọi deployment đều thủ công và dễ lỗi.

Đây là bài cuối cùng trong series TypeScript. Nó bao quát toàn bộ pipeline chất lượng-và-phân-phối: unit test, API test, end-to-end test, chiến lược mocking, CI/CD với GitHub Actions, Docker containerization và tự động hóa deployment.

Điều kiện tiên quyết: Deep Dive: Database & ORM Thời gian cam kết: 2–3 giờ

Bạn Sẽ Học Được Gì

✅ Viết unit test nhanh với Vitest
✅ Test API với Supertest và các pattern integration
✅ Chạy end-to-end test với Playwright
✅ Mock dịch vụ bên ngoài với MSW (Mock Service Worker)
✅ Đo lường và bắt buộc test coverage
✅ Thiết lập CI/CD pipeline với GitHub Actions
✅ Đóng gói ứng dụng với Docker multi-stage build
✅ Tự động hóa deployment lên production


Kim Tự Tháp Testing trong TypeScript

TầngCông cụTốc độTest cái gì
UnitVitest~1ms mỗi testHàm thuần, utility, logic nghiệp vụ
IntegrationSupertest + Vitest~50-200ms mỗi testAPI route, middleware, database query
E2EPlaywright~1-5s mỗi testLuồng người dùng đầy đủ qua trình duyệt

Quy tắc: Viết nhiều unit test, ít hơn integration test, và ít hơn nữa E2E test. Mỗi tầng bắt được các loại bug khác nhau.


Phần 1: Unit Testing với Vitest

Tại Sao Vitest Thay Vì Jest?

Vitest được xây dựng cho hệ sinh thái TypeScript hiện đại:

  • Hỗ trợ ESM và TypeScript native (không cần ts-jest hay cấu hình Babel)
  • Tương thích với API của Jest (dễ dàng migration)
  • Sử dụng pipeline transform của Vite — nhanh hơn đáng kể
  • Coverage tích hợp sẵn với v8 hoặc istanbul
  • Watch mode với re-run tức thì

Thiết Lập

npm install -D vitest @vitest/coverage-v8
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import path from 'path';
 
export default defineConfig({
  test: {
    globals: true,         // Không cần import describe, it, expect
    environment: 'node',   // Dùng 'jsdom' cho frontend component
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html', 'lcov'],
      exclude: [
        'node_modules/',
        'test/',
        '**/*.config.*',
        '**/*.d.ts',
      ],
    },
    include: ['src/**/*.test.ts', 'test/**/*.test.ts'],
  },
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
    },
  },
});
// package.json scripts
{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run",
    "test:coverage": "vitest run --coverage"
  }
}

Viết Unit Test

Unit test tập trung vào logic thuần — các hàm nhận đầu vào và trả về đầu ra mà không có side effect.

// src/utils/slug.ts
export function slugify(text: string): string {
  return text
    .toLowerCase()
    .trim()
    .replace(/[^\w\s-]/g, '')
    .replace(/[\s_]+/g, '-')
    .replace(/^-+|-+$/g, '');
}
 
export function extractTags(content: string): string[] {
  const tagPattern = /#(\w+)/g;
  const matches = content.matchAll(tagPattern);
  return [...new Set([...matches].map(m => m[1].toLowerCase()))];
}
// src/utils/slug.test.ts
import { describe, it, expect } from 'vitest';
import { slugify, extractTags } from './slug';
 
describe('slugify', () => {
  it('chuyển text thành lowercase kebab-case', () => {
    expect(slugify('Hello World')).toBe('hello-world');
  });
 
  it('loại bỏ ký tự đặc biệt', () => {
    expect(slugify('Hello! @World#')).toBe('hello-world');
  });
 
  it('cắt bỏ gạch ngang đầu và cuối', () => {
    expect(slugify('--hello--')).toBe('hello');
  });
 
  it('gộp nhiều khoảng trắng và gạch dưới', () => {
    expect(slugify('hello   world__test')).toBe('hello-world-test');
  });
 
  it('xử lý chuỗi rỗng', () => {
    expect(slugify('')).toBe('');
  });
});
 
describe('extractTags', () => {
  it('trích xuất hashtag từ nội dung', () => {
    const content = 'Learning #typescript and #react today';
    expect(extractTags(content)).toEqual(['typescript', 'react']);
  });
 
  it('loại bỏ trùng lặp tag', () => {
    const content = '#typescript #TypeScript #TYPESCRIPT';
    expect(extractTags(content)).toEqual(['typescript']);
  });
 
  it('trả về mảng rỗng khi không tìm thấy tag', () => {
    expect(extractTags('no tags here')).toEqual([]);
  });
});

Testing Logic Nghiệp Vụ

// src/services/pricing.ts
interface PricingTier {
  name: string;
  minQuantity: number;
  pricePerUnit: number;
}
 
const tiers: PricingTier[] = [
  { name: 'enterprise', minQuantity: 100, pricePerUnit: 5 },
  { name: 'business',   minQuantity: 10,  pricePerUnit: 8 },
  { name: 'starter',    minQuantity: 1,   pricePerUnit: 12 },
];
 
export function calculatePrice(quantity: number): {
  tier: string;
  unitPrice: number;
  total: number;
  savings: number;
} {
  if (quantity <= 0) throw new Error('Quantity must be positive');
 
  const tier = tiers.find(t => quantity >= t.minQuantity)!;
  const basePrice = quantity * tiers[tiers.length - 1].pricePerUnit;
  const total = quantity * tier.pricePerUnit;
 
  return {
    tier: tier.name,
    unitPrice: tier.pricePerUnit,
    total,
    savings: basePrice - total,
  };
}
// src/services/pricing.test.ts
import { describe, it, expect } from 'vitest';
import { calculatePrice } from './pricing';
 
describe('calculatePrice', () => {
  it('sử dụng tier starter cho số lượng nhỏ', () => {
    const result = calculatePrice(5);
    expect(result).toEqual({
      tier: 'starter',
      unitPrice: 12,
      total: 60,
      savings: 0,
    });
  });
 
  it('áp dụng giảm giá business cho 10+ đơn vị', () => {
    const result = calculatePrice(10);
    expect(result.tier).toBe('business');
    expect(result.unitPrice).toBe(8);
    expect(result.savings).toBe(40); // 120 - 80
  });
 
  it('áp dụng giảm giá enterprise cho 100+ đơn vị', () => {
    const result = calculatePrice(100);
    expect(result.tier).toBe('enterprise');
    expect(result.total).toBe(500);
    expect(result.savings).toBe(700); // 1200 - 500
  });
 
  it('throw lỗi cho số lượng bằng 0 hoặc âm', () => {
    expect(() => calculatePrice(0)).toThrow('Quantity must be positive');
    expect(() => calculatePrice(-1)).toThrow('Quantity must be positive');
  });
});

Testing Bất Đồng Bộ

// src/services/email-validator.ts
export async function validateEmail(email: string): Promise<{
  valid: boolean;
  reason?: string;
}> {
  if (!email.includes('@')) {
    return { valid: false, reason: 'Missing @ symbol' };
  }
 
  const [local, domain] = email.split('@');
 
  if (!local || local.length === 0) {
    return { valid: false, reason: 'Empty local part' };
  }
 
  if (!domain || !domain.includes('.')) {
    return { valid: false, reason: 'Invalid domain' };
  }
 
  const disposableDomains = ['tempmail.com', 'throwaway.email'];
  if (disposableDomains.includes(domain)) {
    return { valid: false, reason: 'Disposable email not allowed' };
  }
 
  return { valid: true };
}
// src/services/email-validator.test.ts
import { describe, it, expect } from 'vitest';
import { validateEmail } from './email-validator';
 
describe('validateEmail', () => {
  it('chấp nhận email hợp lệ', async () => {
    const result = await validateEmail('[email protected]');
    expect(result).toEqual({ valid: true });
  });
 
  it('từ chối email không có @', async () => {
    const result = await validateEmail('invalid');
    expect(result).toEqual({ valid: false, reason: 'Missing @ symbol' });
  });
 
  it('từ chối nhà cung cấp email dùng một lần', async () => {
    const result = await validateEmail('[email protected]');
    expect(result.valid).toBe(false);
    expect(result.reason).toContain('Disposable');
  });
});

Phần 2: Integration Testing với Supertest

Integration test kiểm tra rằng các API route, middleware và database hoạt động cùng nhau đúng cách.

Thiết Lập

npm install -D supertest @types/supertest

Điều quan trọng là export app Express/Hono riêng biệt khỏi phần khởi động server:

// src/app.ts — export app (không có .listen())
import express from 'express';
import { userRouter } from './routes/users';
import { postRouter } from './routes/posts';
import { errorHandler } from './middleware/error-handler';
 
const app = express();
app.use(express.json());
app.use('/api/users', userRouter);
app.use('/api/posts', postRouter);
app.use(errorHandler);
 
export { app };
// src/server.ts — khởi động server (chỉ trong production/dev)
import { app } from './app';
const port = process.env.PORT || 3000;
app.listen(port, () => console.log(`Server running on port ${port}`));

Viết API Test

// test/api/users.test.ts
import { describe, it, expect, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import { app } from '../../src/app';
import { prisma } from '../../src/lib/prisma';
 
async function cleanDatabase() {
  await prisma.$transaction([
    prisma.comment.deleteMany(),
    prisma.post.deleteMany(),
    prisma.user.deleteMany(),
  ]);
}
 
describe('POST /api/users', () => {
  beforeEach(cleanDatabase);
  afterAll(() => prisma.$disconnect());
 
  it('tạo user mới', async () => {
    const response = await request(app)
      .post('/api/users')
      .send({
        email: '[email protected]',
        name: 'Alice',
        password: 'securePassword123',
      })
      .expect(201);
 
    expect(response.body).toMatchObject({
      email: '[email protected]',
      name: 'Alice',
    });
    expect(response.body).not.toHaveProperty('password');
    expect(response.body).toHaveProperty('id');
  });
 
  it('trả về 400 cho email không hợp lệ', async () => {
    const response = await request(app)
      .post('/api/users')
      .send({ email: 'not-an-email', name: 'Test' })
      .expect(400);
 
    expect(response.body.error).toBeDefined();
  });
 
  it('trả về 409 cho email trùng lặp', async () => {
    // Tạo user đầu tiên
    await request(app)
      .post('/api/users')
      .send({ email: '[email protected]', name: 'Alice', password: 'pass123' });
 
    // Thử tạo trùng lặp
    await request(app)
      .post('/api/users')
      .send({ email: '[email protected]', name: 'Bob', password: 'pass456' })
      .expect(409);
  });
});
 
describe('GET /api/users/:id', () => {
  beforeEach(cleanDatabase);
  afterAll(() => prisma.$disconnect());
 
  it('trả về user theo ID', async () => {
    const created = await request(app)
      .post('/api/users')
      .send({ email: '[email protected]', name: 'Alice', password: 'pass123' });
 
    const response = await request(app)
      .get(`/api/users/${created.body.id}`)
      .expect(200);
 
    expect(response.body.email).toBe('[email protected]');
  });
 
  it('trả về 404 cho user không tồn tại', async () => {
    await request(app)
      .get('/api/users/non-existent-id')
      .expect(404);
  });
});

Testing Route Được Bảo Vệ

// test/api/posts.test.ts
import { describe, it, expect, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import { app } from '../../src/app';
import { prisma } from '../../src/lib/prisma';
import { generateToken } from '../../src/lib/jwt';
 
describe('POST /api/posts', () => {
  let authToken: string;
  let userId: string;
 
  beforeEach(async () => {
    await prisma.$transaction([
      prisma.comment.deleteMany(),
      prisma.post.deleteMany(),
      prisma.user.deleteMany(),
    ]);
 
    const user = await prisma.user.create({
      data: { email: '[email protected]', name: 'Author', role: 'USER' },
    });
    userId = user.id;
    authToken = generateToken({ userId: user.id, role: user.role });
  });
 
  afterAll(() => prisma.$disconnect());
 
  it('tạo bài viết khi đã xác thực', async () => {
    const response = await request(app)
      .post('/api/posts')
      .set('Authorization', `Bearer ${authToken}`)
      .send({ title: 'My Post', content: 'Post content' })
      .expect(201);
 
    expect(response.body.title).toBe('My Post');
    expect(response.body.authorId).toBe(userId);
  });
 
  it('trả về 401 khi không có auth token', async () => {
    await request(app)
      .post('/api/posts')
      .send({ title: 'My Post', content: 'Content' })
      .expect(401);
  });
 
  it('trả về 403 khi không đủ quyền', async () => {
    const viewer = await prisma.user.create({
      data: { email: '[email protected]', name: 'Viewer', role: 'USER' },
    });
    const viewerToken = generateToken({ userId: viewer.id, role: 'VIEWER' });
 
    await request(app)
      .post('/api/posts')
      .set('Authorization', `Bearer ${viewerToken}`)
      .send({ title: 'Post', content: 'Content' })
      .expect(403);
  });
});

Phần 3: Mocking với MSW (Mock Service Worker)

Khi code của bạn gọi API bên ngoài (Stripe, SendGrid, GitHub), bạn không muốn test gọi đến dịch vụ thật. MSW chặn HTTP request ở tầng network — code của bạn không biết nó đang bị mock.

Thiết Lập

npm install -D msw
// test/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
 
export const handlers = [
  // Mock Stripe payment intent
  http.post('https://api.stripe.com/v1/payment_intents', () => {
    return HttpResponse.json({
      id: 'pi_test_123',
      status: 'succeeded',
      amount: 2000,
      currency: 'usd',
    });
  }),
 
  // Mock GitHub user API
  http.get('https://api.github.com/users/:username', ({ params }) => {
    return HttpResponse.json({
      login: params.username,
      id: 12345,
      avatar_url: `https://avatars.githubusercontent.com/u/12345`,
      name: 'Test User',
      public_repos: 42,
    });
  }),
 
  // Mock dịch vụ email
  http.post('https://api.resend.com/emails', async ({ request }) => {
    const body = await request.json() as Record<string, unknown>;
    return HttpResponse.json({
      id: 'email_test_456',
      to: body.to,
      status: 'sent',
    });
  }),
];
// test/mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
 
export const server = setupServer(...handlers);
// test/setup.ts — đăng ký trong vitest config globalSetup hoặc setupFiles
import { beforeAll, afterEach, afterAll } from 'vitest';
import { server } from './mocks/server';
 
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

Sử Dụng Mock trong Test

// src/services/github.ts
export async function getGitHubProfile(username: string) {
  const response = await fetch(`https://api.github.com/users/${username}`, {
    headers: { Accept: 'application/vnd.github.v3+json' },
  });
 
  if (!response.ok) throw new Error(`GitHub API error: ${response.status}`);
  return response.json();
}
// src/services/github.test.ts
import { describe, it, expect } from 'vitest';
import { http, HttpResponse } from 'msw';
import { server } from '../../test/mocks/server';
import { getGitHubProfile } from './github';
 
describe('getGitHubProfile', () => {
  it('trả về profile user', async () => {
    const profile = await getGitHubProfile('octocat');
 
    expect(profile.login).toBe('octocat');
    expect(profile.public_repos).toBe(42);
  });
 
  it('throw lỗi khi API error', async () => {
    // Ghi đè handler mặc định cho test này
    server.use(
      http.get('https://api.github.com/users/:username', () => {
        return new HttpResponse(null, { status: 404 });
      })
    );
 
    await expect(getGitHubProfile('nonexistent')).rejects.toThrow(
      'GitHub API error: 404'
    );
  });
 
  it('xử lý rate limiting', async () => {
    server.use(
      http.get('https://api.github.com/users/:username', () => {
        return new HttpResponse(null, {
          status: 429,
          headers: { 'Retry-After': '60' },
        });
      })
    );
 
    await expect(getGitHubProfile('octocat')).rejects.toThrow(
      'GitHub API error: 429'
    );
  });
});

Tại sao MSW thay vì mock thủ công?

// ❌ Mock thủ công — gắn chặt test với implementation
vi.mock('node-fetch', () => ({
  default: vi.fn().mockResolvedValue({
    ok: true,
    json: () => Promise.resolve({ login: 'octocat' }),
  }),
}));
// Vấn đề: Nếu bạn chuyển từ fetch sang axios, tất cả mock bị hỏng
 
// ✅ MSW — mock ở tầng network
// Code của bạn dùng fetch/axios thật. MSW chặn HTTP request.
// Đổi HTTP client? Test vẫn hoạt động.

Phần 4: E2E Testing với Playwright

End-to-end test kiểm tra rằng toàn bộ ứng dụng hoạt động từ góc nhìn người dùng — click button, điền form, điều hướng trang.

Thiết Lập

npm install -D @playwright/test
npx playwright install  # Tải browser binary
// playwright.config.ts
import { defineConfig } from '@playwright/test';
 
export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,       // Fail nếu để test.only trong CI
  retries: process.env.CI ? 2 : 0,    // Retry test flaky trong CI
  workers: process.env.CI ? 1 : undefined,
 
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',          // Ghi trace khi thất bại
    screenshot: 'only-on-failure',
  },
 
  projects: [
    { name: 'chromium', use: { browserName: 'chromium' } },
    { name: 'firefox',  use: { browserName: 'firefox' } },
    { name: 'webkit',   use: { browserName: 'webkit' } },
  ],
 
  // Khởi động dev server trước khi test
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
    timeout: 30_000,
  },
});

Viết E2E Test

// e2e/auth.spec.ts
import { test, expect } from '@playwright/test';
 
test.describe('Authentication', () => {
  test('user có thể đăng ký và đăng nhập', async ({ page }) => {
    // Điều hướng đến trang đăng ký
    await page.goto('/signup');
 
    // Điền form đăng ký
    await page.getByLabel('Email').fill('[email protected]');
    await page.getByLabel('Password').fill('securePass123');
    await page.getByLabel('Confirm Password').fill('securePass123');
    await page.getByRole('button', { name: 'Sign Up' }).click();
 
    // Phải chuyển hướng đến dashboard
    await expect(page).toHaveURL('/dashboard');
    await expect(page.getByText('Welcome')).toBeVisible();
  });
 
  test('hiển thị lỗi cho thông tin đăng nhập không hợp lệ', async ({ page }) => {
    await page.goto('/login');
 
    await page.getByLabel('Email').fill('[email protected]');
    await page.getByLabel('Password').fill('wrongpass');
    await page.getByRole('button', { name: 'Log In' }).click();
 
    await expect(page.getByText('Invalid credentials')).toBeVisible();
    await expect(page).toHaveURL('/login'); // Ở lại trang login
  });
});
// e2e/blog.spec.ts
import { test, expect } from '@playwright/test';
 
test.describe('Blog', () => {
  test('hiển thị danh sách bài viết', async ({ page }) => {
    await page.goto('/blog');
 
    // Kiểm tra bài viết được render
    const posts = page.getByRole('article');
    await expect(posts).not.toHaveCount(0);
 
    // Mỗi bài viết có tiêu đề và ngày
    const firstPost = posts.first();
    await expect(firstPost.getByRole('heading')).toBeVisible();
    await expect(firstPost.getByText(/\d{4}/)).toBeVisible(); // Năm trong ngày
  });
 
  test('điều hướng đến bài viết riêng lẻ', async ({ page }) => {
    await page.goto('/blog');
 
    // Click bài viết đầu tiên
    const firstTitle = page.getByRole('article').first().getByRole('heading');
    const titleText = await firstTitle.textContent();
    await firstTitle.click();
 
    // Phải ở trên trang bài viết
    await expect(page.getByRole('heading', { level: 1 })).toHaveText(titleText!);
    await expect(page.getByRole('article')).toBeVisible();
  });
 
  test('tìm kiếm lọc bài viết', async ({ page }) => {
    await page.goto('/blog');
 
    await page.getByPlaceholder('Search').fill('typescript');
    await page.keyboard.press('Enter');
 
    // Tất cả bài viết hiển thị phải chứa "typescript"
    const posts = page.getByRole('article');
    const count = await posts.count();
 
    for (let i = 0; i < count; i++) {
      const text = await posts.nth(i).textContent();
      expect(text?.toLowerCase()).toContain('typescript');
    }
  });
});

Page Object Model

Với test suite lớn hơn, đóng gói tương tác trang trong các object tái sử dụng:

// e2e/pages/login.page.ts
import { Page, Locator, expect } from '@playwright/test';
 
export class LoginPage {
  readonly page: Page;
  readonly emailInput: Locator;
  readonly passwordInput: Locator;
  readonly submitButton: Locator;
  readonly errorMessage: Locator;
 
  constructor(page: Page) {
    this.page = page;
    this.emailInput = page.getByLabel('Email');
    this.passwordInput = page.getByLabel('Password');
    this.submitButton = page.getByRole('button', { name: 'Log In' });
    this.errorMessage = page.getByRole('alert');
  }
 
  async goto() {
    await this.page.goto('/login');
  }
 
  async login(email: string, password: string) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }
 
  async expectError(message: string) {
    await expect(this.errorMessage).toContainText(message);
  }
}
// e2e/login.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/login.page';
 
test('đăng nhập với thông tin hợp lệ', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('[email protected]', 'password123');
 
  await expect(page).toHaveURL('/dashboard');
});
 
test('đăng nhập với thông tin không hợp lệ', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('[email protected]', 'wrong');
 
  await loginPage.expectError('Invalid credentials');
});

Chạy Playwright Test

# Chạy tất cả E2E test
npx playwright test
 
# Chạy chế độ có giao diện (nhìn thấy trình duyệt)
npx playwright test --headed
 
# Chạy file test cụ thể
npx playwright test e2e/auth.spec.ts
 
# Xem báo cáo test
npx playwright show-report
 
# Chế độ debug với inspector
npx playwright test --debug
 
# Tạo test bằng cách ghi lại thao tác trình duyệt
npx playwright codegen http://localhost:3000

Phần 5: Test Coverage và Quality Gate

Cấu Hình Coverage

// vitest.config.ts (phần coverage)
export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html', 'lcov'],
      thresholds: {
        lines: 80,
        functions: 80,
        branches: 75,
        statements: 80,
      },
      exclude: [
        'node_modules/',
        'test/',
        'e2e/',
        '**/*.config.*',
        '**/*.d.ts',
        'src/types/',
      ],
    },
  },
});
# Chạy test với coverage
npm run test:coverage
 
# Output:
# ----------------------|---------|----------|---------|---------|
# File                  | % Stmts | % Branch | % Funcs | % Lines |
# ----------------------|---------|----------|---------|---------|
# All files             |   87.5  |   82.3   |   91.2  |   87.5  |
#  src/services/        |   92.1  |   88.5   |   95.0  |   92.1  |
#  src/utils/           |  100.0  |  100.0   |  100.0  |  100.0  |
#  src/routes/          |   78.3  |   71.0   |   85.0  |   78.3  |
# ----------------------|---------|----------|---------|---------|

Coverage là metric, không phải mục tiêu. 80% coverage với assertion có ý nghĩa đáng giá hơn 100% coverage với test nông cạn chỉ gọi hàm mà không kiểm tra kết quả.


Phần 6: CI/CD với GitHub Actions

Pipeline CI Cơ Bản

# .github/workflows/ci.yml
name: CI
 
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
 
jobs:
  lint-and-typecheck:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
 
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
 
      - run: npm ci
 
      - name: Lint
        run: npm run lint
 
      - name: Type check
        run: npx tsc --noEmit
 
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
 
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
 
      - run: npm ci
 
      - name: Run unit tests
        run: npm run test:coverage
 
      - name: Upload coverage
        uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: coverage/
 
  integration-tests:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_USER: testuser
          POSTGRES_PASSWORD: testpass
          POSTGRES_DB: testdb
        ports:
          - 5432:5432
        options: >-
          --health-cmd="pg_isready"
          --health-interval=10s
          --health-timeout=5s
          --health-retries=5
 
    steps:
      - uses: actions/checkout@v4
 
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
 
      - run: npm ci
 
      - name: Run migrations
        run: npx prisma migrate deploy
        env:
          DATABASE_URL: postgresql://testuser:testpass@localhost:5432/testdb
 
      - name: Run integration tests
        run: npm run test:integration
        env:
          DATABASE_URL: postgresql://testuser:testpass@localhost:5432/testdb
 
  e2e-tests:
    runs-on: ubuntu-latest
    needs: [lint-and-typecheck, unit-tests]
    steps:
      - uses: actions/checkout@v4
 
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
 
      - run: npm ci
 
      - name: Install Playwright browsers
        run: npx playwright install --with-deps
 
      - name: Run E2E tests
        run: npx playwright test
        env:
          DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}
 
      - name: Upload test results
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: playwright-report
          path: playwright-report/

Trực Quan Hóa Pipeline

Các quyết định thiết kế quan trọng:

  • Lint, unit và integration chạy song song (không có dependency giữa chúng)
  • E2E đợi lint và unit pass (không cần chạy browser test tốn kém nếu kiểm tra cơ bản fail)
  • Deploy chỉ chạy trên nhánh main sau khi tất cả test pass

Thêm Deployment

  deploy:
    runs-on: ubuntu-latest
    needs: [lint-and-typecheck, unit-tests, integration-tests, e2e-tests]
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
 
    steps:
      - uses: actions/checkout@v4
 
      - name: Deploy to Vercel
        uses: amondnet/vercel-action@v25
        with:
          vercel-token: ${{ secrets.VERCEL_TOKEN }}
          vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
          vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
          vercel-args: '--prod'

Phần 7: Docker Multi-Stage Build

Docker đảm bảo ứng dụng của bạn chạy giống hệt nhau trong development, CI và production — không còn "chạy được trên máy tôi."

Dockerfile cho Production

# Giai đoạn 1: Cài đặt dependency
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts
 
# Giai đoạn 2: Build ứng dụng
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
 
# Sinh Prisma client
RUN npx prisma generate
 
# Build app
RUN npm run build
 
# Giai đoạn 3: Image production
FROM node:20-alpine AS runner
WORKDIR /app
 
ENV NODE_ENV=production
 
# Tạo user không có quyền root cho bảo mật
RUN addgroup --system --gid 1001 nodejs && \
    adduser --system --uid 1001 appuser
 
# Chỉ copy những gì cần để chạy
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/prisma ./prisma
 
USER appuser
 
EXPOSE 3000
 
CMD ["node", "dist/server.js"]

Tại sao multi-stage?

  • Giai đoạn deps: Cài dependency (được cache trừ khi package.json thay đổi)
  • Giai đoạn builder: Biên dịch TypeScript (có devDependencies + source code)
  • Giai đoạn runner: Chỉ chứa code đã biên dịch + dependency production

Kết quả: Image cuối cùng khoảng ~150MB thay vì ~1GB.

Docker Compose cho Development

# docker-compose.yml
services:
  app:
    build:
      context: .
      dockerfile: Dockerfile.dev
    ports:
      - '3000:3000'
    volumes:
      - .:/app
      - /app/node_modules  # Không ghi đè node_modules
    environment:
      - DATABASE_URL=postgresql://devuser:devpass@db:5432/devdb
      - NODE_ENV=development
    depends_on:
      db:
        condition: service_healthy
 
  db:
    image: postgres:16-alpine
    ports:
      - '5432:5432'
    environment:
      POSTGRES_USER: devuser
      POSTGRES_PASSWORD: devpass
      POSTGRES_DB: devdb
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ['CMD-SHELL', 'pg_isready -U devuser -d devdb']
      interval: 5s
      timeout: 5s
      retries: 5
 
volumes:
  postgres_data:
# Dockerfile.dev
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install
COPY . .
CMD ["npm", "run", "dev"]

.dockerignore

node_modules
dist
.git
.env*
*.md
playwright-report
coverage

Phần 8: Quản Lý Environment

Pattern Cấu Hình

// src/config.ts
import { z } from 'zod';
 
const envSchema = z.object({
  NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
  PORT: z.coerce.number().default(3000),
  DATABASE_URL: z.string().url(),
  JWT_SECRET: z.string().min(32),
  REDIS_URL: z.string().url().optional(),
  STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
  RESEND_API_KEY: z.string().startsWith('re_'),
  LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
});
 
// Validate khi khởi động — fail nhanh nếu config sai
const parsed = envSchema.safeParse(process.env);
 
if (!parsed.success) {
  console.error('Biến môi trường không hợp lệ:', parsed.error.flatten().fieldErrors);
  process.exit(1);
}
 
export const config = parsed.data;
// Sử dụng — fully typed, không có process.env rải rác
import { config } from './config';
 
const server = app.listen(config.PORT);
const stripe = new Stripe(config.STRIPE_SECRET_KEY);

File Environment

# .env.example — commit vào git (không có secret)
NODE_ENV=development
PORT=3000
DATABASE_URL=postgresql://devuser:devpass@localhost:5432/devdb
JWT_SECRET=development-secret-key-change-in-production
STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxx
RESEND_API_KEY=re_xxxxxxxxxxxx
 
# .env — KHÔNG commit (chứa secret thật)
# .env.test — ghi đè environment test
# .env.production — ghi đè production (hoặc dùng CI secret)
# .gitignore
.env
.env.local
.env.production
!.env.example

Phần 9: Tự Động Hóa Deployment

Vercel (Serverless)

Vercel tự động deploy từ Git. Cấu hình với vercel.json:

{
  "buildCommand": "npx prisma generate && npm run build",
  "framework": null,
  "outputDirectory": "dist"
}

Cho database migration trong production:

# .github/workflows/migrate.yml
name: Database Migration
 
on:
  push:
    branches: [main]
    paths:
      - 'prisma/migrations/**'
 
jobs:
  migrate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
      - run: npm ci
      - name: Run migrations
        run: npx prisma migrate deploy
        env:
          DATABASE_URL: ${{ secrets.PRODUCTION_DATABASE_URL }}

Self-Hosted (Docker + SSH)

# .github/workflows/deploy.yml
name: Deploy
 
on:
  push:
    branches: [main]
 
jobs:
  deploy:
    runs-on: ubuntu-latest
    needs: [ci]  # Tham chiếu CI job của bạn
 
    steps:
      - uses: actions/checkout@v4
 
      - name: Build Docker image
        run: docker build -t myapp:${{ github.sha }} .
 
      - name: Push to container registry
        run: |
          echo ${{ secrets.REGISTRY_PASSWORD }} | docker login ghcr.io -u ${{ github.actor }} --password-stdin
          docker tag myapp:${{ github.sha }} ghcr.io/${{ github.repository }}:latest
          docker push ghcr.io/${{ github.repository }}:latest
 
      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.DEPLOY_SSH_KEY }}
          script: |
            docker pull ghcr.io/${{ github.repository }}:latest
            docker compose up -d --force-recreate app
            docker image prune -f

Health Check

Luôn bao gồm endpoint health cho monitoring và container orchestration:

// src/routes/health.ts
import { Router } from 'express';
import { prisma } from '../lib/prisma';
 
const router = Router();
 
router.get('/health', async (req, res) => {
  const checks: Record<string, string> = {};
 
  // Kiểm tra database
  try {
    await prisma.$queryRaw`SELECT 1`;
    checks.database = 'ok';
  } catch {
    checks.database = 'error';
  }
 
  const allHealthy = Object.values(checks).every(s => s === 'ok');
 
  res.status(allHealthy ? 200 : 503).json({
    status: allHealthy ? 'healthy' : 'degraded',
    checks,
    uptime: process.uptime(),
    timestamp: new Date().toISOString(),
  });
});
 
export { router as healthRouter };

Tổng Hợp Lại

Đây là luồng phát triển-đến-production hoàn chỉnh:

Script package.json đầy đủ:

{
  "scripts": {
    "dev": "tsx watch src/server.ts",
    "build": "tsc && tsc-alias",
    "start": "node dist/server.js",
    "lint": "eslint src/",
    "typecheck": "tsc --noEmit",
    "test": "vitest",
    "test:run": "vitest run",
    "test:coverage": "vitest run --coverage",
    "test:integration": "vitest run --config vitest.integration.config.ts",
    "test:e2e": "playwright test",
    "test:e2e:headed": "playwright test --headed",
    "test:e2e:debug": "playwright test --debug",
    "db:migrate": "prisma migrate dev",
    "db:migrate:prod": "prisma migrate deploy",
    "db:seed": "prisma db seed",
    "db:studio": "prisma studio",
    "docker:dev": "docker compose up -d",
    "docker:build": "docker build -t myapp .",
    "docker:run": "docker run -p 3000:3000 --env-file .env myapp"
  }
}

Tóm Tắt và Bài Học Chính

Kết Quả Học Tập:
✅ Vitest là lựa chọn hiện đại cho TypeScript testing — nhanh, ESM native, API tương thích Jest
✅ Supertest cho phép test HTTP endpoint mà không cần khởi động server — kết hợp với test database thật
✅ Playwright bao phủ luồng user đầy đủ trên các trình duyệt — sử dụng Page Object Model cho khả năng bảo trì
✅ MSW mock API bên ngoài ở tầng network — code của bạn không biết nó đang bị mock
✅ Coverage threshold ngăn regression — nhưng assertion có ý nghĩa quan trọng hơn phần trăm
✅ GitHub Actions chạy lint, typecheck, unit, integration và E2E test song song để phản hồi nhanh
✅ Docker multi-stage build tạo image production nhỏ, bảo mật — tách riêng giai đoạn build và runtime
✅ Environment validation với Zod bắt lỗi config ngay khi khởi động, không phải lúc 3 giờ sáng trên production

Testing không phải công việc thêm — đó là công việc giúp bạn ship tự tin. CI/CD không phải overhead — đó là hệ thống bắt vấn đề trước khi user gặp phải. Cùng nhau, chúng khép kín vòng lặp từ "code chạy trên máy tôi" đến "code chạy trên production."

Hoàn Thành Series

Đây là bài cuối cùng trong TypeScript Full-Stack Roadmap. Qua 10 bài, chúng ta đã bao quát:

  1. Tổng Quan Roadmap — bức tranh tổng thể
  2. TypeScript Fundamentals — type, interface, generic
  3. Frontend Development — React + TypeScript
  4. Backend Development — Node.js + Express
  5. Full-Stack Integration — kết nối frontend và backend
  6. Advanced Types — mapped type, conditional type, template literal
  7. React Best Practices — pattern cho React production
  8. Node.js API Development — xây dựng API production
  9. Database & ORM — Prisma, Drizzle, tối ưu query
  10. Testing & DevOps — bài viết này

Bạn đã có bộ công cụ hoàn chỉnh để xây dựng, test và deploy ứng dụng TypeScript từ đầu đến production.


Đây là bài #10 (cuối cùng) trong series TypeScript Full-Stack Roadmap.

📬 Đă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.