Day 10: CI/CD and Best Practices
What You'll Learn Today
- Running Jest in CI environments (GitHub Actions)
- Pre-commit hooks with Husky + lint-staged
- Test strategy: what to test, what not to test
- Writing maintainable tests
- Test organization patterns
- Dealing with flaky tests
- Performance optimization
- Testing legacy code
- Jest ecosystem: useful plugins and tools
Running Jest in CI Environments
Continuous Integration (CI) ensures your tests run automatically on every push, maintaining code quality throughout the project lifecycle.
flowchart LR
subgraph Dev["Development"]
PUSH["git push"]
end
subgraph CI["CI/CD Pipeline"]
BUILD["Build"]
TEST["Run Tests"]
LINT["Lint"]
DEPLOY["Deploy"]
end
PUSH --> BUILD --> TEST --> LINT --> DEPLOY
style Dev fill:#3b82f6,color:#fff
style CI fill:#22c55e,color:#fff
GitHub Actions Configuration
# .github/workflows/test.yml
name: Test
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test -- --ci --coverage --maxWorkers=2
- name: Upload coverage
if: matrix.node-version == 20
uses: codecov/codecov-action@v4
with:
files: ./coverage/lcov.info
CI-Specific Jest Configuration
// jest.config.js
module.exports = {
ci: process.env.CI === 'true',
collectCoverage: process.env.CI === 'true',
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
};
TypeScript version:
// jest.config.ts
import type { Config } from 'jest';
const config: Config = {
collectCoverage: process.env.CI === 'true',
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
};
export default config;
Tip: The
--ciflag disables automatic snapshot updates and fails the test if a new snapshot is found. This prevents unintentional snapshot changes from being committed.
Pre-commit Hooks (Husky + lint-staged)
Pre-commit hooks prevent low-quality code from entering your repository by running lint and tests before each commit.
Setup
# Install Husky and lint-staged
npm install --save-dev husky lint-staged
# Initialize Husky
npx husky init
Configuration
// package.json
{
"lint-staged": {
"*.{js,ts,jsx,tsx}": [
"eslint --fix",
"jest --bail --findRelatedTests"
]
}
}
# .husky/pre-commit
npx lint-staged
flowchart TB
subgraph Hook["Pre-commit Hook"]
COMMIT["git commit"]
STAGED["Detect changed files"]
LINT["Run ESLint"]
TEST["Run related tests"]
PASS["Commit succeeds"]
FAIL["Commit aborted"]
end
COMMIT --> STAGED --> LINT --> TEST
TEST -->|Pass| PASS
TEST -->|Fail| FAIL
style Hook fill:#8b5cf6,color:#fff
Important:
--findRelatedTestsonly runs tests related to the changed files, minimizing commit-time wait.
Test Strategy: What to Test, What Not to Test
What You Should Test
| Category | Examples |
|---|---|
| Business logic | Calculations, data transformations, validation |
| Edge cases | Empty arrays, null, boundary values |
| Error handling | Exception paths, fallback behavior |
| Public API | Function inputs/outputs, rendered component output |
| Integration points | API calls, database operations |
What You Should Not Test
| Category | Reason |
|---|---|
| Implementation details | Breaks on refactoring |
| Third-party libraries | Already tested by maintainers |
| Constant values | They don't change |
| Pure CSS styling | Use visual testing instead |
| Private methods | Test through public API |
flowchart TB
subgraph Do["Should Test"]
BL["Business Logic"]
EDGE["Edge Cases"]
ERR["Error Handling"]
API["Public API"]
end
subgraph Dont["Should Not Test"]
IMPL["Implementation Details"]
LIB["Third-party Libraries"]
CONST["Constants"]
PRIV["Private Methods"]
end
style Do fill:#22c55e,color:#fff
style Dont fill:#ef4444,color:#fff
The Testing Pyramid
flowchart TB
E2E["E2E Tests\n(Few, Slow, Expensive)"]
INT["Integration Tests\n(Moderate)"]
UNIT["Unit Tests\n(Many, Fast, Cheap)"]
E2E --- INT --- UNIT
style E2E fill:#ef4444,color:#fff
style INT fill:#f59e0b,color:#fff
style UNIT fill:#22c55e,color:#fff
Writing Maintainable Tests
Anti-pattern: Testing Implementation Details
// Bad: testing internal state
test('adds item to cart', () => {
const cart = new ShoppingCart();
cart.addItem({ id: 1, name: 'Book', price: 1500 });
// Implementation detail: testing internal array
expect(cart._items).toHaveLength(1);
expect(cart._items[0].id).toBe(1);
});
// Good: testing public behavior
test('adds item to cart', () => {
const cart = new ShoppingCart();
cart.addItem({ id: 1, name: 'Book', price: 1500 });
expect(cart.getItemCount()).toBe(1);
expect(cart.getTotalPrice()).toBe(1500);
expect(cart.hasItem(1)).toBe(true);
});
TypeScript version:
interface CartItem {
id: number;
name: string;
price: number;
}
// Good: testing public behavior
test('adds item to cart', () => {
const cart = new ShoppingCart();
const item: CartItem = { id: 1, name: 'Book', price: 1500 };
cart.addItem(item);
expect(cart.getItemCount()).toBe(1);
expect(cart.getTotalPrice()).toBe(1500);
expect(cart.hasItem(1)).toBe(true);
});
Best Practice: AAA Pattern
test('applies discount to total price', () => {
// Arrange: set up test data
const cart = new ShoppingCart();
cart.addItem({ id: 1, name: 'Book', price: 2000 });
cart.addItem({ id: 2, name: 'Pen', price: 500 });
// Act: perform the action
cart.applyDiscount(0.1); // 10% off
// Assert: verify the result
expect(cart.getTotalPrice()).toBe(2250);
});
Test Naming Conventions
// Good: describes behavior
describe('ShoppingCart', () => {
describe('applyDiscount', () => {
test('reduces total price by the given percentage', () => {});
test('throws error when discount is negative', () => {});
test('does not apply discount when cart is empty', () => {});
});
});
// Bad: vague names
describe('ShoppingCart', () => {
test('discount works', () => {});
test('test error', () => {});
});
Test Organization Patterns
Pattern 1: By Feature (Recommended)
src/
βββ features/
β βββ auth/
β β βββ login.ts
β β βββ login.test.ts
β β βββ register.ts
β β βββ register.test.ts
β βββ cart/
β βββ cart.ts
β βββ cart.test.ts
β βββ checkout.ts
β βββ checkout.test.ts
Pattern 2: By Type
src/
βββ components/
β βββ Button.tsx
β βββ Header.tsx
βββ __tests__/
β βββ unit/
β β βββ Button.test.tsx
β β βββ Header.test.tsx
β βββ integration/
β β βββ checkout.test.ts
β βββ e2e/
β βββ purchase-flow.test.ts
| Pattern | Pros | Cons |
|---|---|---|
| By feature (colocation) | Tests close to code, easy to find when making changes | Directories can grow large |
| By type | Clear separation of test types | Tests are far from source code |
Recommendation: For small to medium projects, colocation (by feature) is the most maintainable approach. Place test files next to the code they test.
Dealing with Flaky Tests
Flaky tests are tests that randomly pass or fail for the same code. They erode trust in your test suite.
Common Causes and Solutions
| Cause | Solution |
|---|---|
| Shared state between tests | Reset state in beforeEach/afterEach |
| Timing dependencies | Use waitFor, fake timers |
| External service dependencies | Replace with mocks |
| Random data | Use fixed seed values |
| Test order dependencies | Detect with --randomize |
// Bad: timing-dependent
test('shows notification after delay', () => {
showNotification('Hello');
expect(screen.getByText('Hello')).toBeInTheDocument(); // may fail
});
// Good: use fake timers
test('shows notification after delay', () => {
jest.useFakeTimers();
showNotification('Hello');
jest.advanceTimersByTime(1000);
expect(screen.getByText('Hello')).toBeInTheDocument();
jest.useRealTimers();
});
// Bad: shared state
let counter = 0;
test('increments counter', () => {
counter++;
expect(counter).toBe(1);
});
test('counter is still 1', () => {
expect(counter).toBe(1); // depends on execution order
});
// Good: isolated state
describe('counter tests', () => {
let counter;
beforeEach(() => {
counter = 0;
});
test('increments counter', () => {
counter++;
expect(counter).toBe(1);
});
test('starts fresh', () => {
expect(counter).toBe(0);
});
});
Detecting Flaky Tests
# Run tests in random order
npx jest --randomize
# Run a test multiple times to check flakiness
for i in {1..10}; do npx jest --testPathPattern="suspect.test" || break; done
Performance Optimization
As projects grow, test execution speed becomes critical for developer productivity.
Key Flags
| Flag | Description | Use Case |
|---|---|---|
--maxWorkers=N |
Limit worker count | Control resources in CI |
--runInBand |
Serial execution (no workers) | Memory-constrained environments |
--shard=i/n |
Split tests into N shards, run shard i | Parallel CI |
--onlyChanged |
Only run tests related to changed files | Local development |
--bail |
Stop on first failure | Fast feedback |
Sharding in CI
# .github/workflows/test.yml
jobs:
test:
strategy:
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npx jest --shard=${{ matrix.shard }}/4
flowchart LR
subgraph Sharding["Test Sharding"]
ALL["All Tests\n(400 tests)"]
S1["Shard 1\n100 tests"]
S2["Shard 2\n100 tests"]
S3["Shard 3\n100 tests"]
S4["Shard 4\n100 tests"]
end
ALL --> S1
ALL --> S2
ALL --> S3
ALL --> S4
style Sharding fill:#3b82f6,color:#fff
Additional Optimization Techniques
// jest.config.js
module.exports = {
// Transform cache to speed up subsequent runs
cacheDirectory: '/tmp/jest-cache',
// Only collect coverage for source files
collectCoverageFrom: [
'src/**/*.{js,ts,jsx,tsx}',
'!src/**/*.d.ts',
'!src/**/index.ts',
],
// Skip expensive transforms for node_modules
transformIgnorePatterns: ['/node_modules/'],
};
Testing Legacy Code
An incremental approach for adding tests to untested legacy code.
Step 1: Characterization Tests
First, document the current behavior.
// Legacy function with no tests
function calculateShipping(weight, destination) {
if (destination === 'domestic') {
if (weight < 1) return 500;
if (weight < 5) return 800;
return 1200;
}
if (weight < 1) return 2000;
if (weight < 5) return 3500;
return 5000;
}
// Characterization test: document current behavior
describe('calculateShipping (characterization)', () => {
test('domestic shipping rates', () => {
expect(calculateShipping(0.5, 'domestic')).toBe(500);
expect(calculateShipping(3, 'domestic')).toBe(800);
expect(calculateShipping(10, 'domestic')).toBe(1200);
});
test('international shipping rates', () => {
expect(calculateShipping(0.5, 'international')).toBe(2000);
expect(calculateShipping(3, 'international')).toBe(3500);
expect(calculateShipping(10, 'international')).toBe(5000);
});
});
Step 2: Refactor Safely
With tests in place, you can refactor with confidence.
// Refactored version
interface ShippingRate {
maxWeight: number;
price: number;
}
const DOMESTIC_RATES: ShippingRate[] = [
{ maxWeight: 1, price: 500 },
{ maxWeight: 5, price: 800 },
{ maxWeight: Infinity, price: 1200 },
];
const INTERNATIONAL_RATES: ShippingRate[] = [
{ maxWeight: 1, price: 2000 },
{ maxWeight: 5, price: 3500 },
{ maxWeight: Infinity, price: 5000 },
];
function calculateShipping(weight: number, destination: string): number {
const rates = destination === 'domestic' ? DOMESTIC_RATES : INTERNATIONAL_RATES;
const rate = rates.find(r => weight < r.maxWeight);
return rate?.price ?? rates[rates.length - 1].price;
}
Step 3: Gradually Increase Coverage
flowchart LR
subgraph Strategy["Incremental Testing Strategy"]
S1["1. Characterization\nDocument behavior"]
S2["2. Refactor\nProtected by tests"]
S3["3. New Code\nAlways test new code"]
S4["4. Coverage\nPrioritize critical paths"]
end
S1 --> S2 --> S3 --> S4
style Strategy fill:#8b5cf6,color:#fff
Rule: Always write tests for new code. Add tests to legacy code when you modify it.
Jest Ecosystem: Useful Plugins and Tools
jest-extended
A collection of additional matchers that extend Jest's built-in set.
npm install --save-dev jest-extended
// jest.config.js
module.exports = {
setupFilesAfterSetup: ['jest-extended/all'],
};
// Useful additional matchers
test('jest-extended matchers', () => {
expect([1, 2, 3]).toBeArray();
expect([1, 2, 3]).toIncludeAllMembers([1, 3]);
expect('hello').toStartWith('he');
expect('hello').toEndWith('lo');
expect(5).toBeWithin(1, 10);
expect({ a: 1 }).toContainKey('a');
expect(() => {}).toBeFunction();
expect(new Date()).toBeDate();
expect('').toBeEmpty();
expect(42).toBePositive();
expect(-1).toBeNegative();
});
@testing-library/jest-dom
Custom matchers for testing DOM elements.
npm install --save-dev @testing-library/jest-dom
import '@testing-library/jest-dom';
test('DOM matchers', () => {
document.body.innerHTML = `
<button disabled class="primary">Submit</button>
<input type="text" value="hello" />
<div style="display: none">Hidden</div>
`;
const button = document.querySelector('button');
const input = document.querySelector('input');
const hidden = document.querySelector('div');
expect(button).toBeDisabled();
expect(button).toHaveClass('primary');
expect(button).toHaveTextContent('Submit');
expect(input).toHaveValue('hello');
expect(hidden).not.toBeVisible();
});
Other Useful Tools
| Tool | Description |
|---|---|
jest-extended |
Additional matchers (toBeArray, toStartWith, etc.) |
@testing-library/jest-dom |
DOM-specific matchers |
jest-watch-typeahead |
Filter by filename/test name in watch mode |
jest-html-reporters |
Generate HTML test reports |
jest-image-snapshot |
Visual regression testing |
jest-when |
Conditional mock return values based on arguments |
Summary
| Concept | Description |
|---|---|
| CI/CD | Automatically run tests to ensure code quality |
| Pre-commit hooks | Run lint and tests before each commit |
| Test strategy | Focus on business logic, avoid implementation details |
| AAA pattern | Structure tests as Arrange, Act, Assert |
| Colocation | Place test files next to source code |
| Flaky tests | Fix with state isolation and timing control |
| Sharding | Split tests across parallel CI jobs |
| Characterization tests | Document legacy code behavior before refactoring |
| jest-extended | Plugin that adds additional matchers |
Key Takeaways
- Use
--ciand--maxWorkersto control resources in CI environments - Use
--findRelatedTestsin pre-commit hooks for speed - Test public behavior, not implementation details
- Identify and fix the root cause of flaky tests
- Start with characterization tests when dealing with legacy code
Exercises
Exercise 1: Basic
Fill in the blanks in the following GitHub Actions workflow.
name: Test
on:
push:
branches: [main]
jobs:
test:
runs-on: ______
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npx jest --__ --coverage --maxWorkers=__
Exercise 2: Applied
The following test suite has a flaky test issue. Identify the problem and fix it.
let database = [];
describe('UserRepository', () => {
test('adds a user', () => {
database.push({ id: 1, name: 'Alice' });
expect(database).toHaveLength(1);
});
test('finds a user by id', () => {
const user = database.find(u => u.id === 1);
expect(user).toBeDefined();
expect(user.name).toBe('Alice');
});
test('database starts empty', () => {
expect(database).toHaveLength(0); // This fails!
});
});
Challenge
Create a jest.config.js that meets these requirements:
- Collect coverage only in CI environments
- Coverage threshold of 80% minimum
- Cache directory at
/tmp/jest-cache - Exclude
node_modulesand.d.tsfiles from coverage - Limit to 2 workers in CI environments
References
- Jest - CLI Options
- Jest - Configuration
- GitHub Actions - Node.js
- Husky
- lint-staged
- jest-extended
- Testing Library - jest-dom
Reflecting on Your 10-Day Journey
Congratulations! You have completed the entire 10-day Jest learning journey. Let's look back at everything you've learned.
| Day | Topic | What You Learned |
|---|---|---|
| Day 1 | Welcome to Jest | Setting up Jest, writing your first test |
| Day 2 | Test Structure and Basic Patterns | describe/test, beforeEach/afterEach |
| Day 3 | Mastering Matchers | toBe, toEqual, various matchers |
| Day 4 | Mocks, Stubs, and Spies | jest.fn(), jest.mock(), jest.spyOn() |
| Day 5 | Testing Async Code | async/await, Promises, timers |
| Day 6 | Test Coverage and Debugging | Coverage reports, debugging techniques |
| Day 7 | React Component Testing | Testing Library, user events |
| Day 8 | Advanced Mock Patterns | Module mocks, manual mocks |
| Day 9 | Snapshot Testing | toMatchSnapshot, toMatchInlineSnapshot |
| Day 10 | CI/CD and Best Practices | CI setup, test strategy, performance |
flowchart TB
subgraph Journey["Your 10-Day Jest Journey"]
D1["Day 1-3\nFoundations"]
D2["Day 4-6\nIntermediate"]
D3["Day 7-9\nAdvanced"]
D4["Day 10\nReal-World"]
end
D1 --> D2 --> D3 --> D4
style D1 fill:#3b82f6,color:#fff
style D2 fill:#8b5cf6,color:#fff
style D3 fill:#f59e0b,color:#fff
style D4 fill:#22c55e,color:#fff
Next Steps
From here, take your Jest skills into real-world projects.
- Add tests to an existing project -- Start with characterization tests
- Set up a CI/CD pipeline -- Use the GitHub Actions configuration from today
- Set a coverage goal -- Aim for 80% as your first target
- Dive deeper into Testing Library -- Essential for React projects
- Explore E2E testing (Playwright/Cypress) -- For scenarios Jest alone cannot cover
Writing tests is not just about code quality -- it builds confidence as a developer. Keep writing great tests!