Back to blog

Deep Dive: Testing & DevOps for TypeScript

typescripttestingdevopsvitestci-cd
Deep Dive: Testing & DevOps for TypeScript

You've built the full stack — frontend, backend, database. Now comes the part that separates hobby projects from production software: testing and deployment. Without tests, every change is a gamble. Without CI/CD, every deployment is manual and error-prone.

This is the final post in the TypeScript series. It covers the entire quality-and-delivery pipeline: unit tests, API tests, end-to-end tests, mocking strategies, CI/CD with GitHub Actions, Docker containerization, and deployment automation.

Prerequisite: Deep Dive: Database & ORMs Time commitment: 2–3 hours

What You'll Learn

✅ Write fast unit tests with Vitest
✅ Test APIs with Supertest and integration patterns
✅ Run end-to-end tests with Playwright
✅ Mock external services with MSW (Mock Service Worker)
✅ Measure and enforce test coverage
✅ Set up CI/CD pipelines with GitHub Actions
✅ Containerize apps with Docker multi-stage builds
✅ Automate deployments to production


The Testing Pyramid in TypeScript

LayerToolSpeedWhat It Tests
UnitVitest~1ms per testPure functions, utilities, business logic
IntegrationSupertest + Vitest~50-200ms per testAPI routes, middleware, database queries
E2EPlaywright~1-5s per testFull user flows through the browser

The rule: Write many unit tests, fewer integration tests, and even fewer E2E tests. Each layer catches different categories of bugs.


Part 1: Unit Testing with Vitest

Why Vitest Over Jest?

Vitest is built for the modern TypeScript ecosystem:

  • Native ESM and TypeScript support (no ts-jest or Babel config)
  • Compatible with Jest's API (easy migration)
  • Uses Vite's transform pipeline — significantly faster
  • Built-in coverage with v8 or istanbul
  • Watch mode with instant re-runs

Setup

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,         // No need to import describe, it, expect
    environment: 'node',   // Use 'jsdom' for frontend components
    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"
  }
}

Writing Unit Tests

Unit tests focus on pure logic — functions that take input and return output without side effects.

// 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('converts text to lowercase kebab-case', () => {
    expect(slugify('Hello World')).toBe('hello-world');
  });
 
  it('removes special characters', () => {
    expect(slugify('Hello! @World#')).toBe('hello-world');
  });
 
  it('trims leading and trailing hyphens', () => {
    expect(slugify('--hello--')).toBe('hello');
  });
 
  it('collapses multiple spaces and underscores', () => {
    expect(slugify('hello   world__test')).toBe('hello-world-test');
  });
 
  it('handles empty strings', () => {
    expect(slugify('')).toBe('');
  });
});
 
describe('extractTags', () => {
  it('extracts hashtags from content', () => {
    const content = 'Learning #typescript and #react today';
    expect(extractTags(content)).toEqual(['typescript', 'react']);
  });
 
  it('deduplicates tags', () => {
    const content = '#typescript #TypeScript #TYPESCRIPT';
    expect(extractTags(content)).toEqual(['typescript']);
  });
 
  it('returns empty array when no tags found', () => {
    expect(extractTags('no tags here')).toEqual([]);
  });
});

Testing Business Logic

// 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('uses starter tier for small quantities', () => {
    const result = calculatePrice(5);
    expect(result).toEqual({
      tier: 'starter',
      unitPrice: 12,
      total: 60,
      savings: 0,
    });
  });
 
  it('applies business discount for 10+ units', () => {
    const result = calculatePrice(10);
    expect(result.tier).toBe('business');
    expect(result.unitPrice).toBe(8);
    expect(result.savings).toBe(40); // 120 - 80
  });
 
  it('applies enterprise discount for 100+ units', () => {
    const result = calculatePrice(100);
    expect(result.tier).toBe('enterprise');
    expect(result.total).toBe(500);
    expect(result.savings).toBe(700); // 1200 - 500
  });
 
  it('throws for zero or negative quantity', () => {
    expect(() => calculatePrice(0)).toThrow('Quantity must be positive');
    expect(() => calculatePrice(-1)).toThrow('Quantity must be positive');
  });
});

Async Testing

// 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('accepts valid emails', async () => {
    const result = await validateEmail('user@example.com');
    expect(result).toEqual({ valid: true });
  });
 
  it('rejects emails without @', async () => {
    const result = await validateEmail('invalid');
    expect(result).toEqual({ valid: false, reason: 'Missing @ symbol' });
  });
 
  it('rejects disposable email providers', async () => {
    const result = await validateEmail('user@tempmail.com');
    expect(result.valid).toBe(false);
    expect(result.reason).toContain('Disposable');
  });
});

Part 2: Integration Testing with Supertest

Integration tests verify that your API routes, middleware, and database work together correctly.

Setup

npm install -D supertest @types/supertest

The key is to export your Express/Hono app separately from the server start:

// src/app.ts — exports the app (no .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 — starts the server (only in production/dev)
import { app } from './app';
const port = process.env.PORT || 3000;
app.listen(port, () => console.log(`Server running on port ${port}`));

Writing API Tests

// 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('creates a new user', async () => {
    const response = await request(app)
      .post('/api/users')
      .send({
        email: 'alice@example.com',
        name: 'Alice',
        password: 'securePassword123',
      })
      .expect(201);
 
    expect(response.body).toMatchObject({
      email: 'alice@example.com',
      name: 'Alice',
    });
    expect(response.body).not.toHaveProperty('password');
    expect(response.body).toHaveProperty('id');
  });
 
  it('returns 400 for invalid email', async () => {
    const response = await request(app)
      .post('/api/users')
      .send({ email: 'not-an-email', name: 'Test' })
      .expect(400);
 
    expect(response.body.error).toBeDefined();
  });
 
  it('returns 409 for duplicate email', async () => {
    // Create first user
    await request(app)
      .post('/api/users')
      .send({ email: 'alice@example.com', name: 'Alice', password: 'pass123' });
 
    // Try to create duplicate
    await request(app)
      .post('/api/users')
      .send({ email: 'alice@example.com', name: 'Bob', password: 'pass456' })
      .expect(409);
  });
});
 
describe('GET /api/users/:id', () => {
  beforeEach(cleanDatabase);
  afterAll(() => prisma.$disconnect());
 
  it('returns user by ID', async () => {
    const created = await request(app)
      .post('/api/users')
      .send({ email: 'alice@example.com', name: 'Alice', password: 'pass123' });
 
    const response = await request(app)
      .get(`/api/users/${created.body.id}`)
      .expect(200);
 
    expect(response.body.email).toBe('alice@example.com');
  });
 
  it('returns 404 for non-existent user', async () => {
    await request(app)
      .get('/api/users/non-existent-id')
      .expect(404);
  });
});

Testing Protected Routes

// 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: 'author@example.com', name: 'Author', role: 'USER' },
    });
    userId = user.id;
    authToken = generateToken({ userId: user.id, role: user.role });
  });
 
  afterAll(() => prisma.$disconnect());
 
  it('creates a post when authenticated', 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('returns 401 without auth token', async () => {
    await request(app)
      .post('/api/posts')
      .send({ title: 'My Post', content: 'Content' })
      .expect(401);
  });
 
  it('returns 403 for insufficient permissions', async () => {
    const viewer = await prisma.user.create({
      data: { email: 'viewer@example.com', 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);
  });
});

Part 3: Mocking with MSW (Mock Service Worker)

When your code calls external APIs (Stripe, SendGrid, GitHub), you don't want tests hitting real services. MSW intercepts HTTP requests at the network level — your code doesn't know it's being mocked.

Setup

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 email service
  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 — register in vitest config globalSetup or setupFiles
import { beforeAll, afterEach, afterAll } from 'vitest';
import { server } from './mocks/server';
 
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

Using Mocks in Tests

// 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('returns user profile', async () => {
    const profile = await getGitHubProfile('octocat');
 
    expect(profile.login).toBe('octocat');
    expect(profile.public_repos).toBe(42);
  });
 
  it('throws on API error', async () => {
    // Override the default handler for this test
    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('handles 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'
    );
  });
});

Why MSW over manual mocking?

// ❌ Manual mocking — couples tests to implementation
vi.mock('node-fetch', () => ({
  default: vi.fn().mockResolvedValue({
    ok: true,
    json: () => Promise.resolve({ login: 'octocat' }),
  }),
}));
// Problem: If you switch from fetch to axios, all mocks break
 
// ✅ MSW — mocks at the network level
// Your code uses real fetch/axios. MSW intercepts the HTTP request.
// Switch HTTP client? Tests still work.

Part 4: E2E Testing with Playwright

End-to-end tests verify that your entire application works from the user's perspective — clicking buttons, filling forms, navigating pages.

Setup

npm install -D @playwright/test
npx playwright install  # Downloads browser binaries
// playwright.config.ts
import { defineConfig } from '@playwright/test';
 
export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,       // Fail if test.only left in CI
  retries: process.env.CI ? 2 : 0,    // Retry flaky tests in CI
  workers: process.env.CI ? 1 : undefined,
 
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',          // Capture trace on failure
    screenshot: 'only-on-failure',
  },
 
  projects: [
    { name: 'chromium', use: { browserName: 'chromium' } },
    { name: 'firefox',  use: { browserName: 'firefox' } },
    { name: 'webkit',   use: { browserName: 'webkit' } },
  ],
 
  // Start dev server before tests
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
    timeout: 30_000,
  },
});

Writing E2E Tests

// e2e/auth.spec.ts
import { test, expect } from '@playwright/test';
 
test.describe('Authentication', () => {
  test('user can sign up and log in', async ({ page }) => {
    // Navigate to signup
    await page.goto('/signup');
 
    // Fill the signup form
    await page.getByLabel('Email').fill('newuser@example.com');
    await page.getByLabel('Password').fill('securePass123');
    await page.getByLabel('Confirm Password').fill('securePass123');
    await page.getByRole('button', { name: 'Sign Up' }).click();
 
    // Should redirect to dashboard
    await expect(page).toHaveURL('/dashboard');
    await expect(page.getByText('Welcome')).toBeVisible();
  });
 
  test('shows error for invalid credentials', async ({ page }) => {
    await page.goto('/login');
 
    await page.getByLabel('Email').fill('wrong@example.com');
    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'); // Stay on login page
  });
});
// e2e/blog.spec.ts
import { test, expect } from '@playwright/test';
 
test.describe('Blog', () => {
  test('displays list of posts', async ({ page }) => {
    await page.goto('/blog');
 
    // Check that posts are rendered
    const posts = page.getByRole('article');
    await expect(posts).not.toHaveCount(0);
 
    // Each post has a title and date
    const firstPost = posts.first();
    await expect(firstPost.getByRole('heading')).toBeVisible();
    await expect(firstPost.getByText(/\d{4}/)).toBeVisible(); // Year in date
  });
 
  test('navigates to individual post', async ({ page }) => {
    await page.goto('/blog');
 
    // Click the first post
    const firstTitle = page.getByRole('article').first().getByRole('heading');
    const titleText = await firstTitle.textContent();
    await firstTitle.click();
 
    // Should be on the post page
    await expect(page.getByRole('heading', { level: 1 })).toHaveText(titleText!);
    await expect(page.getByRole('article')).toBeVisible();
  });
 
  test('search filters posts', async ({ page }) => {
    await page.goto('/blog');
 
    await page.getByPlaceholder('Search').fill('typescript');
    await page.keyboard.press('Enter');
 
    // All visible posts should contain "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

For larger test suites, encapsulate page interactions in reusable objects:

// 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('login with valid credentials', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('user@example.com', 'password123');
 
  await expect(page).toHaveURL('/dashboard');
});
 
test('login with invalid credentials', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('wrong@example.com', 'wrong');
 
  await loginPage.expectError('Invalid credentials');
});

Running Playwright Tests

# Run all E2E tests
npx playwright test
 
# Run in headed mode (see the browser)
npx playwright test --headed
 
# Run a specific test file
npx playwright test e2e/auth.spec.ts
 
# View test report
npx playwright show-report
 
# Debug mode with inspector
npx playwright test --debug
 
# Generate tests by recording browser actions
npx playwright codegen http://localhost:3000

Part 5: Test Coverage and Quality Gates

Coverage Configuration

// vitest.config.ts (coverage section)
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/',
      ],
    },
  },
});
# Run tests with 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 is a metric, not a goal. 80% coverage with meaningful assertions is worth more than 100% coverage with shallow tests that just call functions without checking results.


Part 6: CI/CD with GitHub Actions

Basic CI Pipeline

# .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/

Pipeline Visualization

The key design decisions:

  • Lint, unit, and integration run in parallel (no dependencies between them)
  • E2E waits for lint and unit to pass (no point running expensive browser tests if basic checks fail)
  • Deploy only runs on the main branch after all tests pass

Adding 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'

Part 7: Docker Multi-Stage Builds

Docker ensures your app runs identically in development, CI, and production — no more "works on my machine."

Production Dockerfile

# Stage 1: Install dependencies
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts
 
# Stage 2: Build the application
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
 
# Generate Prisma client
RUN npx prisma generate
 
# Build the app
RUN npm run build
 
# Stage 3: Production image
FROM node:20-alpine AS runner
WORKDIR /app
 
ENV NODE_ENV=production
 
# Create non-root user for security
RUN addgroup --system --gid 1001 nodejs && \
    adduser --system --uid 1001 appuser
 
# Copy only what's needed to run
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"]

Why multi-stage?

  • deps stage: Installs dependencies (cached unless package.json changes)
  • builder stage: Compiles TypeScript (has devDependencies + source code)
  • runner stage: Only contains compiled code + production dependencies

Result: The final image is ~150MB instead of ~1GB.

Docker Compose for Development

# docker-compose.yml
services:
  app:
    build:
      context: .
      dockerfile: Dockerfile.dev
    ports:
      - '3000:3000'
    volumes:
      - .:/app
      - /app/node_modules  # Don't override 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

Part 8: Environment Management

Configuration Pattern

// 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 at startup — fail fast if config is wrong
const parsed = envSchema.safeParse(process.env);
 
if (!parsed.success) {
  console.error('Invalid environment variables:', parsed.error.flatten().fieldErrors);
  process.exit(1);
}
 
export const config = parsed.data;
// Usage — fully typed, no process.env scattered everywhere
import { config } from './config';
 
const server = app.listen(config.PORT);
const stripe = new Stripe(config.STRIPE_SECRET_KEY);

Environment Files

# .env.example — committed to git (no secrets)
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 — NOT committed (contains real secrets)
# .env.test — test environment overrides
# .env.production — production overrides (or use CI secrets)
# .gitignore
.env
.env.local
.env.production
!.env.example

Part 9: Deployment Automation

Vercel (Serverless)

Vercel auto-deploys from Git. Configure with vercel.json:

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

For database migrations in 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]  # Reference your CI job
 
    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 Checks

Always include a health endpoint for monitoring and 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> = {};
 
  // Database check
  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 };

Putting It All Together

Here's the complete development-to-production flow:

The complete package.json scripts:

{
  "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"
  }
}

Summary and Key Takeaways

Learning Outcomes:
Vitest is the modern choice for TypeScript testing — fast, native ESM, Jest-compatible API
Supertest lets you test HTTP endpoints without starting a server — pair with a real test database
Playwright covers full user flows across browsers — use the Page Object Model for maintainability
MSW mocks external APIs at the network level — your code doesn't know it's being mocked
Coverage thresholds prevent regressions — but meaningful assertions matter more than percentages
GitHub Actions runs lint, typecheck, unit, integration, and E2E tests in parallel for fast feedback
Docker multi-stage builds produce small, secure production images — separate build and runtime stages
Environment validation with Zod catches config errors at startup, not at 3 AM in production

Testing isn't extra work — it's the work that lets you ship confidently. CI/CD isn't overhead — it's the system that catches problems before your users do. Together, they close the loop from "code works on my machine" to "code works in production."

Series Complete

This is the final post in the TypeScript Full-Stack Roadmap. Over 10 posts, we've covered:

  1. Roadmap Overview — the big picture
  2. TypeScript Fundamentals — types, interfaces, generics
  3. Frontend Development — React + TypeScript
  4. Backend Development — Node.js + Express
  5. Full-Stack Integration — connecting frontend and backend
  6. Advanced Types — mapped types, conditional types, template literals
  7. React Best Practices — patterns for production React
  8. Node.js API Development — building production APIs
  9. Database & ORMs — Prisma, Drizzle, query optimization
  10. Testing & DevOps — this post

You now have the complete toolkit to build, test, and deploy TypeScript applications from scratch to production.


This is post #10 (final) in the TypeScript Full-Stack Roadmap series.

📬 Subscribe to Newsletter

Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.

We respect your privacy. Unsubscribe at any time.

💬 Comments

Sign in to leave a comment

We'll never post without your permission.