Unit Testing Interview Questions (Free Preview)
Free sample of 15 from 37 questions available
Unit Testing Fundamentals
What is the difference between unit tests and integration tests?
The 30-Second Answer: Unit tests verify individual components in complete isolation using mocks for dependencies, running in milliseconds. Integration tests verify that multiple components work together correctly with real dependencies, running slower but catching issues that unit tests miss. You need both: many fast unit tests for detailed coverage, fewer integration tests for confidence that the system actually works.
The 2-Minute Answer (If They Want More): The fundamental difference lies in scope and what you're testing. Unit tests focus on a single "unit" - typically one function, method, or class - in complete isolation from everything else. Integration tests focus on how multiple units work together, testing the interactions between components, modules, or even entire systems.
Unit tests replace all external dependencies with test doubles (mocks, stubs, fakes). Testing a UserService? Mock the database, mock the email sender, mock the authentication service. This isolation makes unit tests incredibly fast (milliseconds), deterministic, and easy to debug. When a unit test fails, you know exactly which function has the problem. Unit tests verify that your code logic is correct assuming dependencies work as expected.
Integration tests use real dependencies or at least more realistic versions. Testing that same UserService? Use a real test database, real email service (or a realistic fake), real authentication. Integration tests are slower (seconds to minutes), more complex to set up, and harder to debug because failures could originate from any component. But they verify that components actually work together - that your SQL queries are valid, your API calls use the right format, your configuration is correct.
The testing pyramid illustrates the ideal balance: a wide base of many unit tests (70-80%), a middle layer of integration tests (15-20%), and a small top of end-to-end tests (5-10%). This ratio maximizes speed and maintainability while still catching real-world issues.
Common integration test scenarios include: database operations with a real test database, API endpoint tests hitting actual routes, service-to-service communication tests, file system operations, and third-party library integrations. These tests ensure that your mocks in unit tests actually reflect reality.
Both test types are essential. Unit tests give you rapid feedback during development and pinpoint logic errors. Integration tests give you confidence that the application actually works and catch configuration issues, interface mismatches, and incorrect assumptions about how dependencies behave.
Code Example:
// The code being tested
class OrderService {
constructor(database, paymentGateway, emailService) {
this.db = database;
this.payment = paymentGateway;
this.email = emailService;
}
async createOrder(userId, items) {
// Validate items
if (!items || items.length === 0) {
throw new Error('Order must contain items');
}
// Calculate total
const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
// Process payment
const paymentResult = await this.payment.charge(userId, total);
if (!paymentResult.success) {
throw new Error('Payment failed');
}
// Save to database
const order = await this.db.orders.create({
userId,
items,
total,
paymentId: paymentResult.id
});
// Send confirmation email
await this.email.sendOrderConfirmation(userId, order);
return order;
}
}
// ===== UNIT TEST - Tests OrderService in complete isolation =====
describe('OrderService - Unit Tests', () => {
let orderService;
let mockDb;
let mockPayment;
let mockEmail;
beforeEach(() => {
// Create mocks for all dependencies
mockDb = {
orders: {
create: jest.fn()
}
};
mockPayment = {
charge: jest.fn()
};
mockEmail = {
sendOrderConfirmation: jest.fn()
};
orderService = new OrderService(mockDb, mockPayment, mockEmail);
});
it('should create order with valid items', async () => {
// Arrange
const items = [
{ id: 1, price: 10, quantity: 2 },
{ id: 2, price: 5, quantity: 1 }
];
mockPayment.charge.mockResolvedValue({ success: true, id: 'pay_123' });
mockDb.orders.create.mockResolvedValue({
id: 'order_123',
userId: 'user_1',
items,
total: 25
});
// Act
const order = await orderService.createOrder('user_1', items);
// Assert
expect(order.id).toBe('order_123');
expect(mockPayment.charge).toHaveBeenCalledWith('user_1', 25);
expect(mockDb.orders.create).toHaveBeenCalledWith({
userId: 'user_1',
items,
total: 25,
paymentId: 'pay_123'
});
expect(mockEmail.sendOrderConfirmation).toHaveBeenCalled();
});
it('should throw error when items array is empty', async () => {
// Act & Assert
await expect(orderService.createOrder('user_1', []))
.rejects.toThrow('Order must contain items');
// Verify no external calls were made
expect(mockPayment.charge).not.toHaveBeenCalled();
expect(mockDb.orders.create).not.toHaveBeenCalled();
});
it('should throw error when payment fails', async () => {
// Arrange
const items = [{ id: 1, price: 10, quantity: 1 }];
mockPayment.charge.mockResolvedValue({ success: false });
// Act & Assert
await expect(orderService.createOrder('user_1', items))
.rejects.toThrow('Payment failed');
// Verify order was not created
expect(mockDb.orders.create).not.toHaveBeenCalled();
});
// Unit tests run in milliseconds - no real database, payment, or email
});
// ===== INTEGRATION TEST - Tests OrderService with real dependencies =====
describe('OrderService - Integration Tests', () => {
let orderService;
let testDb;
let testPaymentGateway;
let testEmailService;
beforeAll(async () => {
// Set up real test database
testDb = await setupTestDatabase();
// Use real payment gateway in sandbox mode
testPaymentGateway = new PaymentGateway({
apiKey: process.env.TEST_PAYMENT_API_KEY,
sandbox: true
});
// Use real email service pointing to test mailbox
testEmailService = new EmailService({
apiKey: process.env.TEST_EMAIL_API_KEY,
testMode: true
});
orderService = new OrderService(testDb, testPaymentGateway, testEmailService);
});
afterAll(async () => {
// Clean up database connection
await testDb.close();
});
beforeEach(async () => {
// Clean database before each test
await testDb.orders.deleteAll();
await testDb.users.deleteAll();
});
it('should create order end-to-end with real database and services', async () => {
// Arrange - Create test user in database
const user = await testDb.users.create({
id: 'user_integration_1',
email: 'test@example.com',
name: 'Test User'
});
const items = [
{ id: 1, price: 10, quantity: 2 },
{ id: 2, price: 5, quantity: 1 }
];
// Act - Actually creates order, charges payment, sends email
const order = await orderService.createOrder(user.id, items);
// Assert - Verify order was saved to database
const savedOrder = await testDb.orders.findById(order.id);
expect(savedOrder).toBeDefined();
expect(savedOrder.total).toBe(25);
expect(savedOrder.items).toHaveLength(2);
// Verify payment was processed (check sandbox transaction)
const payment = await testPaymentGateway.getTransaction(order.paymentId);
expect(payment.amount).toBe(25);
expect(payment.status).toBe('completed');
// Verify email was sent (check test mailbox)
const emails = await testEmailService.getTestEmails();
const confirmationEmail = emails.find(e =>
e.to === 'test@example.com' &&
e.subject.includes('Order Confirmation')
);
expect(confirmationEmail).toBeDefined();
});
it('should handle database constraint violations', async () => {
// This test catches real database issues that mocks wouldn't reveal
const items = [{ id: 1, price: 10, quantity: 1 }];
// Try to create order for non-existent user
await expect(orderService.createOrder('nonexistent_user', items))
.rejects.toThrow(); // Real database constraint error
});
// Integration tests are slower (seconds) but test real interactions
});
// ===== COMPARISON TABLE =====
/*
┌─────────────────────┬──────────────────────┬─────────────────────────â”
│ Aspect │ Unit Test │ Integration Test │
├─────────────────────┼──────────────────────┼─────────────────────────┤
│ Scope │ Single unit │ Multiple units │
│ Dependencies │ All mocked │ Real or realistic │
│ Speed │ Milliseconds │ Seconds to minutes │
│ Isolation │ Complete │ Limited │
│ Setup complexity │ Simple │ Complex │
│ Debugging │ Easy │ Harder │
│ Quantity │ Many (hundreds) │ Fewer (dozens) │
│ What they catch │ Logic errors │ Integration issues │
│ Example │ Pure function test │ Database query test │
│ Maintenance │ Easy │ More difficult │
└─────────────────────┴──────────────────────┴─────────────────────────â”
*/
Mermaid Diagram:
flowchart TD
subgraph Unit["Unit Test Scope"]
U1[Function/Method]
U2[Mock DB]
U3[Mock API]
U4[Mock Email]
U1 -.mocked.-> U2
U1 -.mocked.-> U3
U1 -.mocked.-> U4
end
subgraph Integration["Integration Test Scope"]
I1[Service Layer]
I2[Real Test DB]
I3[Real API Sandbox]
I4[Real Email Test Mode]
I1 -->|actual calls| I2
I1 -->|actual calls| I3
I1 -->|actual calls| I4
end
subgraph Pyramid["Testing Pyramid"]
P1[E2E Tests<br/>5-10%]
P2[Integration Tests<br/>15-20%]
P3[Unit Tests<br/>70-80%]
P1 --> P2
P2 --> P3
end
U1 -->|Fast<br/>Isolated<br/>Many| Pyramid
I1 -->|Slower<br/>Realistic<br/>Fewer| Pyramid
style Unit fill:#4CAF50
style Integration fill:#2196F3
style Pyramid fill:#FFC107
style P3 fill:#4CAF50
style P2 fill:#2196F3
style P1 fill:#FF9800
References:
- Martin Fowler - Testing Pyramid
- Google Testing Blog - Test Sizes
- Kent C. Dodds - Write tests. Not too many. Mostly integration.
- Jest Integration Testing Guide
What is a unit test and what makes a good unit test?
The 30-Second Answer: A unit test is an automated test that verifies a single, isolated piece of functionality (typically a function or method) in complete isolation from external dependencies. A good unit test is fast, isolated, repeatable, readable, and tests one thing at a time with clear assertions.
The 2-Minute Answer (If They Want More): A unit test focuses on testing the smallest testable part of an application in isolation. The "unit" is typically a single function, method, or class, tested independently from databases, file systems, network calls, or other external dependencies. Unit tests form the foundation of the testing pyramid, where you should have many unit tests, fewer integration tests, and even fewer end-to-end tests.
What makes a good unit test? First, it must be fast - running in milliseconds so developers can run thousands of tests frequently. Second, it must be isolated - not dependent on other tests, external systems, or shared state. Third, it must be repeatable - producing the same results every time with the same inputs. Fourth, it must be self-validating - clearly passing or failing without manual inspection. Finally, it should be readable and maintainable - serving as living documentation of how the code should behave.
Good unit tests also follow the principle of testing behavior, not implementation. They should verify what the code does, not how it does it. This means focusing on inputs and outputs rather than internal state or private methods. When implementation details change but behavior remains the same, tests shouldn't break.
The best unit tests are so clear that when they fail, you immediately understand what broke and why. They have descriptive names, clear assertions, and minimal setup code. They test one concept per test, making failures easy to diagnose and fix.
Code Example:
// Example: Testing a simple calculation function
// Function to test
function calculateDiscount(price, discountPercent) {
if (price < 0 || discountPercent < 0 || discountPercent > 100) {
throw new Error('Invalid input');
}
return price * (1 - discountPercent / 100);
}
// Good unit test - fast, isolated, clear
describe('calculateDiscount', () => {
it('should apply 20% discount to $100 price', () => {
const result = calculateDiscount(100, 20);
expect(result).toBe(80);
});
it('should return original price with 0% discount', () => {
const result = calculateDiscount(50, 0);
expect(result).toBe(50);
});
it('should throw error for negative price', () => {
expect(() => calculateDiscount(-10, 20)).toThrow('Invalid input');
});
it('should throw error for discount over 100%', () => {
expect(() => calculateDiscount(100, 150)).toThrow('Invalid input');
});
});
// Bad unit test - tests too much at once
describe('calculateDiscount', () => {
it('should work correctly', () => {
// Testing multiple scenarios in one test - hard to debug when it fails
expect(calculateDiscount(100, 20)).toBe(80);
expect(calculateDiscount(50, 0)).toBe(50);
expect(() => calculateDiscount(-10, 20)).toThrow();
});
});
Mermaid Diagram:
flowchart TD
A[Good Unit Test] --> B[Fast <br/> Runs in milliseconds]
A --> C[Isolated <br/> No external dependencies]
A --> D[Repeatable <br/> Same input = same output]
A --> E[Self-Validating <br/> Clear pass/fail]
A --> F[Readable <br/> Documents behavior]
B --> G[Developer Confidence]
C --> G
D --> G
E --> G
F --> G
style A fill:#4CAF50
style G fill:#2196F3
References:
↑ Back to topWhat is test isolation and why is it important?
The 30-Second Answer: Test isolation means each test runs independently without dependencies on other tests, external systems, or shared state. It's crucial because isolated tests are reliable, can run in any order or parallel, fail for clear reasons, and make debugging straightforward - when an isolated test fails, you know exactly what broke.
The 2-Minute Answer (If They Want More): Test isolation is the principle that each test should be completely self-contained and independent. An isolated test can run alone, run first, run last, or run in parallel with other tests, and it will always produce the same result. It doesn't depend on side effects from previous tests, doesn't affect subsequent tests, and doesn't rely on external systems being in a particular state.
There are several dimensions to test isolation. Test-to-test isolation means tests don't share state or depend on execution order. External dependency isolation means tests don't depend on databases, APIs, file systems, or other external resources. Time-based isolation means tests don't depend on the current date, time, or random values. Each of these is achieved through different techniques.
Why does isolation matter so much? First, it makes tests deterministic - they pass or fail consistently for the same code. Flaky tests that sometimes pass and sometimes fail destroy developer trust and waste time. Second, isolation enables parallel execution, dramatically speeding up test suites. Third, isolated tests make debugging trivial - when a test fails, you know the problem is in that specific unit, not cascading from something else. Fourth, isolated tests serve as reliable documentation of how individual components should behave.
Without isolation, you end up with fragile test suites where tests pass in development but fail in CI, fail on Tuesdays but pass on Wednesdays, or mysteriously fail when run in a different order. These "flaky tests" are worse than no tests at all because they train developers to ignore test failures.
Achieving isolation requires discipline: use beforeEach to set up fresh state, use mocks/stubs for external dependencies, avoid global variables, inject dependencies rather than hard-coding them, and clean up any resources in afterEach. The extra effort pays off with fast, reliable, maintainable tests.
Code Example:
// ❌ Poor isolation - tests share state
describe('Counter (Bad Example)', () => {
let counter = 0; // Shared across all tests!
it('should increment counter', () => {
counter++;
expect(counter).toBe(1); // Passes when run alone
});
it('should increment counter twice', () => {
counter += 2;
expect(counter).toBe(2); // FAILS! counter is 3 (1 from previous test + 2)
});
it('should reset counter', () => {
counter = 0;
expect(counter).toBe(0); // Passes but affects other tests
});
// If you run just the second test alone, it passes!
// This is a classic isolation problem
});
// âś… Good isolation - each test independent
describe('Counter (Good Example)', () => {
let counter;
beforeEach(() => {
counter = 0; // Fresh state for every test
});
it('should increment counter', () => {
counter++;
expect(counter).toBe(1);
});
it('should increment counter twice', () => {
counter += 2;
expect(counter).toBe(2); // Always passes
});
it('should reset counter', () => {
counter = 10;
counter = 0;
expect(counter).toBe(0);
});
// All tests pass regardless of order or which ones run
});
// ❌ Poor isolation - depends on external database
describe('UserRepository (Bad Example)', () => {
it('should create user', async () => {
const db = await connectToRealDatabase();
const user = await db.users.create({ email: 'test@example.com' });
expect(user.id).toBeDefined();
// What if database is down? Test fails
// What if test runs twice? Duplicate email error
});
it('should find user by email', async () => {
const db = await connectToRealDatabase();
// Depends on previous test having created the user!
const user = await db.users.findByEmail('test@example.com');
expect(user).toBeDefined();
});
});
// âś… Good isolation - mocks external dependencies
describe('UserRepository (Good Example)', () => {
let mockDb;
let userRepository;
beforeEach(() => {
// Fresh mock for each test
mockDb = {
users: {
create: jest.fn(),
findByEmail: jest.fn()
}
};
userRepository = new UserRepository(mockDb);
});
it('should create user', async () => {
mockDb.users.create.mockResolvedValue({ id: 1, email: 'test@example.com' });
const user = await userRepository.create({ email: 'test@example.com' });
expect(user.id).toBe(1);
expect(mockDb.users.create).toHaveBeenCalledWith({ email: 'test@example.com' });
});
it('should find user by email', async () => {
mockDb.users.findByEmail.mockResolvedValue({ id: 1, email: 'test@example.com' });
const user = await userRepository.findByEmail('test@example.com');
expect(user.email).toBe('test@example.com');
expect(mockDb.users.findByEmail).toHaveBeenCalledWith('test@example.com');
});
});
// ❌ Poor isolation - depends on current time
describe('Subscription (Bad Example)', () => {
it('should determine if subscription is active', () => {
const subscription = {
startDate: new Date('2025-01-01'),
endDate: new Date('2025-12-31')
};
const isActive = subscription.endDate > new Date(); // Depends when test runs!
expect(isActive).toBe(true); // Will fail in 2026
});
});
// âś… Good isolation - injects time dependency
describe('Subscription (Good Example)', () => {
function isSubscriptionActive(subscription, currentDate) {
return subscription.endDate > currentDate;
}
it('should be active when current date is before end date', () => {
const subscription = {
startDate: new Date('2025-01-01'),
endDate: new Date('2025-12-31')
};
const testDate = new Date('2025-06-15');
const isActive = isSubscriptionActive(subscription, testDate);
expect(isActive).toBe(true);
});
it('should be inactive when current date is after end date', () => {
const subscription = {
startDate: new Date('2025-01-01'),
endDate: new Date('2025-12-31')
};
const testDate = new Date('2026-01-15');
const isActive = isSubscriptionActive(subscription, testDate);
expect(isActive).toBe(false);
});
});
// Proper cleanup for file system tests
describe('FileWriter (Good Example)', () => {
const testFilePath = '/tmp/test-file.txt';
afterEach(() => {
// Clean up after each test to maintain isolation
if (fs.existsSync(testFilePath)) {
fs.unlinkSync(testFilePath);
}
});
it('should write content to file', () => {
const writer = new FileWriter();
writer.write(testFilePath, 'Hello World');
const content = fs.readFileSync(testFilePath, 'utf-8');
expect(content).toBe('Hello World');
});
it('should overwrite existing file', () => {
const writer = new FileWriter();
fs.writeFileSync(testFilePath, 'Old content');
writer.write(testFilePath, 'New content');
const content = fs.readFileSync(testFilePath, 'utf-8');
expect(content).toBe('New content');
});
});
Mermaid Diagram:
flowchart TD
A[Test Isolation] --> B[Test-to-Test<br/>Isolation]
A --> C[External Dependency<br/>Isolation]
A --> D[Time-Based<br/>Isolation]
B --> B1[beforeEach setup]
B --> B2[No shared state]
B --> B3[afterEach cleanup]
C --> C1[Mock databases]
C --> C2[Stub APIs]
C --> C3[Fake file system]
D --> D1[Inject time]
D --> D2[Control randomness]
D --> D3[Deterministic values]
B1 --> E[Benefits]
B2 --> E
B3 --> E
C1 --> E
C2 --> E
C3 --> E
D1 --> E
D2 --> E
D3 --> E
E --> F[Reliable tests]
E --> G[Parallel execution]
E --> H[Easy debugging]
E --> I[Fast feedback]
style A fill:#4CAF50
style E fill:#2196F3
style F fill:#FFC107
style G fill:#FFC107
style H fill:#FFC107
style I fill:#FFC107
References:
↑ Back to topWhat is the AAA pattern (Arrange-Act-Assert)?
The 30-Second Answer: The AAA pattern structures unit tests into three distinct sections: Arrange (set up test data and dependencies), Act (execute the code being tested), and Assert (verify the results). This pattern makes tests more readable, maintainable, and easier to understand by clearly separating setup, execution, and validation.
The 2-Minute Answer (If They Want More): The Arrange-Act-Assert (AAA) pattern is a foundational testing structure that organizes test code into three clear phases. This pattern emerged from the testing community as a way to make tests more consistent and readable across different codebases and teams.
Arrange is the setup phase where you prepare everything needed for the test. This includes creating objects, setting up mock dependencies, initializing variables, and defining expected inputs. The arrange section answers "What context do we need?" Think of it as setting the stage for your test scenario.
Act is the execution phase where you invoke the specific behavior being tested. This is typically a single line of code - calling the function or method under test with the arranged inputs. The act section answers "What are we actually testing?" This should be the shortest section and clearly show what behavior is being verified.
Assert is the verification phase where you check that the actual results match your expectations. This includes verifying return values, checking that methods were called correctly, or validating state changes. The assert section answers "Did it work as expected?" Multiple related assertions are acceptable, but they should all verify aspects of the same behavior.
Some teams use variations like Given-When-Then (Behavior-Driven Development) which maps directly to AAA: Given (Arrange) the context, When (Act) this happens, Then (Assert) expect this result. Both patterns achieve the same goal of making tests readable and well-structured.
Code Example:
// Basic AAA pattern example
describe('ShoppingCart', () => {
it('should calculate total price with tax', () => {
// Arrange - Set up the test data and objects
const cart = new ShoppingCart();
const item1 = { name: 'Book', price: 20 };
const item2 = { name: 'Pen', price: 5 };
cart.addItem(item1);
cart.addItem(item2);
const taxRate = 0.1; // 10% tax
// Act - Execute the behavior being tested
const total = cart.calculateTotal(taxRate);
// Assert - Verify the results
expect(total).toBe(27.5); // (20 + 5) * 1.1 = 27.5
});
});
// More complex example with mocks
describe('EmailService', () => {
it('should send welcome email to new user', async () => {
// Arrange
const mockMailer = {
send: jest.fn().mockResolvedValue({ success: true })
};
const mockTemplateEngine = {
render: jest.fn().mockReturnValue('<h1>Welcome!</h1>')
};
const emailService = new EmailService(mockMailer, mockTemplateEngine);
const newUser = {
email: 'john@example.com',
name: 'John Doe'
};
// Act
const result = await emailService.sendWelcomeEmail(newUser);
// Assert
expect(result.success).toBe(true);
expect(mockTemplateEngine.render).toHaveBeenCalledWith(
'welcome',
{ name: 'John Doe' }
);
expect(mockMailer.send).toHaveBeenCalledWith({
to: 'john@example.com',
subject: 'Welcome!',
html: '<h1>Welcome!</h1>'
});
});
});
// AAA with visual separation (blank lines)
describe('PasswordValidator', () => {
it('should reject password shorter than 8 characters', () => {
// Arrange
const validator = new PasswordValidator({ minLength: 8 });
const shortPassword = 'abc123';
// Act
const result = validator.validate(shortPassword);
// Assert
expect(result.isValid).toBe(false);
expect(result.errors).toContain('Password must be at least 8 characters');
});
});
// Given-When-Then variation (BDD style)
describe('UserAuthentication', () => {
it('should lock account after 3 failed login attempts', () => {
// Given a user with correct credentials
const auth = new UserAuthentication();
const userId = 'user123';
const wrongPassword = 'incorrect';
// When user fails to login 3 times
auth.login(userId, wrongPassword);
auth.login(userId, wrongPassword);
auth.login(userId, wrongPassword);
// Then account should be locked
const account = auth.getAccountStatus(userId);
expect(account.isLocked).toBe(true);
expect(account.lockReason).toBe('Too many failed attempts');
});
});
// Common mistake: Multiple actions in Act phase
describe('OrderProcessor', () => {
it('should process order and send confirmation', () => {
// Arrange
const processor = new OrderProcessor();
const order = { id: 1, items: [{ sku: 'ABC', qty: 2 }] };
// ❌ Bad - Multiple actions (should be split into separate tests)
const processResult = processor.processOrder(order);
const emailResult = processor.sendConfirmation(order);
// Assert
expect(processResult.success).toBe(true);
expect(emailResult.sent).toBe(true);
});
// âś… Better - Separate tests for separate behaviors
it('should successfully process valid order', () => {
const processor = new OrderProcessor();
const order = { id: 1, items: [{ sku: 'ABC', qty: 2 }] };
const result = processor.processOrder(order);
expect(result.success).toBe(true);
});
it('should send confirmation after processing order', () => {
const processor = new OrderProcessor();
const order = { id: 1, items: [{ sku: 'ABC', qty: 2 }] };
processor.processOrder(order);
const result = processor.sendConfirmation(order);
expect(result.sent).toBe(true);
});
});
Mermaid Diagram:
flowchart TD
A[AAA Pattern] --> B[Arrange]
A --> C[Act]
A --> D[Assert]
B --> B1[Create test objects]
B --> B2[Set up mocks]
B --> B3[Prepare test data]
B --> B4[Define inputs]
C --> C1[Execute the method<br/>being tested]
C --> C2[Should be 1-2 lines]
D --> D1[Verify return values]
D --> D2[Check method calls]
D --> D3[Validate state changes]
B1 --> E[Clear, Readable Test]
B2 --> E
B3 --> E
B4 --> E
C1 --> E
C2 --> E
D1 --> E
D2 --> E
D3 --> E
style A fill:#4CAF50
style B fill:#FFC107
style C fill:#2196F3
style D fill:#E91E63
style E fill:#4CAF50
References:
↑ Back to topTest Doubles
What is the difference between a mock and a stub?
The 30-Second Answer: Stubs provide predefined answers to method calls during tests (state verification), while mocks verify that specific methods were called with expected arguments (behavior verification). Stubs answer questions; mocks enforce expectations and cause test failures if not called correctly.
The 2-Minute Answer (If They Want More): The distinction between mocks and stubs is one of the most important concepts in testing, yet it's frequently misunderstood. The key difference lies in what they verify and how they cause tests to fail.
Stubs are all about providing controlled inputs to your system under test. They return predetermined values when called, but they don't care how many times they're called or what arguments they receive. Your test assertions happen on the actual object being tested, not on the stub. If you remove all references to the stub from your assertions and the test still makes sense, you're using a stub correctly.
Mocks, on the other hand, are about verifying interactions. They have built-in expectations about how they should be called - which methods, with what arguments, how many times. Mocks actively participate in test verification; if the expected calls don't happen, the mock itself causes the test to fail. This is behavior verification rather than state verification.
This distinction matters for test design. Mock-heavy tests tend to be more brittle because they're coupled to implementation details (how something is done), while stub-based tests focus on outcomes (what the result is). Generally, I prefer stubs for queries (methods that return values) and mocks for commands (methods that cause side effects), following the Command-Query Separation principle.
Code Example:
// STUB EXAMPLE - State Verification
describe('OrderService with Stub', () => {
test('calculates total with discount', () => {
// Stub just returns a value
const stubPricingService = {
getDiscount: () => 0.1 // Always returns 10% discount
};
const orderService = new OrderService(stubPricingService);
const total = orderService.calculateTotal(100);
// Assertion is on the ORDER SERVICE, not the stub
expect(total).toBe(90);
});
});
// MOCK EXAMPLE - Behavior Verification
describe('OrderService with Mock', () => {
test('sends confirmation email after order', () => {
// Mock expects to be called in a specific way
const mockEmailService = jest.fn();
const orderService = new OrderService(mockEmailService);
orderService.placeOrder({ id: '123', total: 100 });
// Assertion is ON THE MOCK - verifying the interaction happened
expect(mockEmailService).toHaveBeenCalledTimes(1);
expect(mockEmailService).toHaveBeenCalledWith({
to: expect.any(String),
subject: 'Order Confirmation',
orderId: '123'
});
});
});
// COMPARISON: Same scenario, different approaches
class PaymentProcessor {
constructor(paymentGateway, auditLogger) {
this.gateway = paymentGateway;
this.logger = auditLogger;
}
processPayment(amount) {
const result = this.gateway.charge(amount);
this.logger.log(`Payment processed: ${amount}`);
return result;
}
}
// Using STUB for gateway (we care about the return value)
test('processes payment successfully', () => {
const stubGateway = {
charge: () => ({ success: true, id: 'txn_123' })
};
const dummyLogger = { log: () => {} };
const processor = new PaymentProcessor(stubGateway, dummyLogger);
const result = processor.processPayment(100);
// Focus on the OUTCOME
expect(result.success).toBe(true);
});
// Using MOCK for logger (we care that logging happened)
test('logs payment processing', () => {
const stubGateway = {
charge: () => ({ success: true })
};
const mockLogger = jest.fn();
const processor = new PaymentProcessor(stubGateway, mockLogger);
processor.processPayment(100);
// Focus on the INTERACTION
expect(mockLogger).toHaveBeenCalledWith('Payment processed: 100');
});
Mermaid Diagram (if helpful):
flowchart LR
subgraph Stub["Stub (State Verification)"]
S1[Test calls stub] --> S2[Stub returns<br/>canned value]
S2 --> S3[Test asserts on<br/>system under test]
end
subgraph Mock["Mock (Behavior Verification)"]
M1[Test sets<br/>expectations] --> M2[System calls mock]
M2 --> M3[Test verifies mock<br/>was called correctly]
end
style Stub fill:#fff4e1
style Mock fill:#f5e1ff
References:
↑ Back to topWhat is a spy and when would you use it?
The 30-Second Answer: A spy is a test double that records information about how it was called (arguments, call count, return values) while optionally delegating to a real implementation. Use spies when you need to verify interactions but still want partial real behavior, or when you need to assert on calls without setting up strict mock expectations.
The 2-Minute Answer (If They Want More): Spies sit in an interesting middle ground between stubs and mocks. Like stubs, they can return values and don't enforce expectations upfront. Like mocks, they record interaction information that you can assert against. The key difference is that spies can wrap and delegate to real implementations, making them perfect for partial mocking scenarios.
I use spies in several specific situations. First, when I want to verify that a method was called without completely replacing its behavior - for example, checking that a logging method was invoked while still letting it actually log. Second, when I'm not sure exactly what interactions to expect but want to inspect them after the fact during debugging or exploratory testing. Third, when testing legacy code where replacing entire objects with mocks would be too complex, but I need to verify specific method calls.
Spies are particularly valuable in integration tests where you want mostly real behavior but need visibility into specific interactions. They're also essential when using partial mocking patterns, where you want to mock some methods of an object but keep others real. Most modern testing frameworks like Jest make spies easy to create with jest.spyOn(), which wraps existing methods while preserving their original behavior.
The downside of spies is that they can make tests more complex and harder to understand than pure stubs or mocks. They also couple your tests to implementation details since you're verifying how methods are called. I use them judiciously - only when the interaction itself is important enough to verify and when simpler stubs won't provide the confidence I need.
Code Example:
// BASIC SPY: Recording calls without changing behavior
describe('UserService', () => {
test('logs user creation', () => {
const logger = {
info: (message) => console.log(message) // Real implementation
};
// Spy wraps the real method
const logSpy = jest.spyOn(logger, 'info');
const userService = new UserService(logger);
userService.createUser({ name: 'Alice' });
// Verify the interaction while real logging still happened
expect(logSpy).toHaveBeenCalledWith('User created: Alice');
expect(logSpy).toHaveBeenCalledTimes(1);
logSpy.mockRestore(); // Clean up
});
});
// SPY WITH OVERRIDE: Recording calls while providing test behavior
describe('PaymentProcessor', () => {
test('retries on network failure', async () => {
const realGateway = new PaymentGateway();
// Spy but override the return value
const chargeSpy = jest.spyOn(realGateway, 'charge')
.mockRejectedValueOnce(new Error('Network timeout'))
.mockResolvedValueOnce({ success: true });
const processor = new PaymentProcessor(realGateway);
const result = await processor.processPayment(100);
// Verify it was called twice (initial + retry)
expect(chargeSpy).toHaveBeenCalledTimes(2);
expect(chargeSpy).toHaveBeenNthCalledWith(1, 100);
expect(chargeSpy).toHaveBeenNthCalledWith(2, 100);
expect(result.success).toBe(true);
chargeSpy.mockRestore();
});
});
// PARTIAL MOCKING: Spy on some methods, keep others real
class AnalyticsService {
trackEvent(event) {
const timestamp = this.getCurrentTime();
const enriched = this.enrichEvent(event);
return this.send({ ...enriched, timestamp });
}
getCurrentTime() {
return Date.now();
}
enrichEvent(event) {
return { ...event, sessionId: this.sessionId };
}
send(data) {
// Makes actual API call
return fetch('/analytics', { method: 'POST', body: JSON.stringify(data) });
}
}
describe('AnalyticsService', () => {
test('tracks event with timestamp and enrichment', () => {
const service = new AnalyticsService();
service.sessionId = 'session_123';
// Spy on send to prevent actual API call, keep other methods real
const sendSpy = jest.spyOn(service, 'send').mockResolvedValue({ ok: true });
// Spy on getCurrentTime to control timestamp
const timeSpy = jest.spyOn(service, 'getCurrentTime').mockReturnValue(1000);
service.trackEvent({ action: 'click', target: 'button' });
// Verify the send was called with enriched data
expect(sendSpy).toHaveBeenCalledWith({
action: 'click',
target: 'button',
sessionId: 'session_123',
timestamp: 1000
});
sendSpy.mockRestore();
timeSpy.mockRestore();
});
});
// INSPECTION AND DEBUGGING: Using spy to understand calls
describe('Complex workflow', () => {
test('debug what arguments are passed', () => {
const dataService = {
fetch: jest.fn().mockResolvedValue({ data: [] }),
transform: jest.fn(data => data),
validate: jest.fn(() => true)
};
const workflow = new DataWorkflow(dataService);
workflow.execute({ filter: 'active' });
// Inspect exactly what was called and with what
console.log('fetch calls:', dataService.fetch.mock.calls);
console.log('transform calls:', dataService.transform.mock.calls);
console.log('validate calls:', dataService.validate.mock.calls);
// Access detailed call information
expect(dataService.fetch.mock.calls[0][0]).toEqual({ filter: 'active' });
expect(dataService.transform.mock.results[0].value).toBeDefined();
});
});
// REAL-WORLD EXAMPLE: Spying on Date for time-dependent tests
describe('SubscriptionService', () => {
test('marks subscription as expired after end date', () => {
// Spy on Date.now to control "current time"
const now = new Date('2024-01-15').getTime();
jest.spyOn(Date, 'now').mockReturnValue(now);
const subscription = {
endDate: new Date('2024-01-01'),
status: 'active'
};
const service = new SubscriptionService();
const result = service.checkStatus(subscription);
expect(result.status).toBe('expired');
expect(result.expiredDays).toBe(14);
// Restore original Date.now
jest.spyOn(Date, 'now').mockRestore();
});
});
// SPY ASSERTIONS: Different ways to verify spy calls
test('spy assertion patterns', () => {
const callback = jest.fn();
someFunction(callback);
// Call count
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalled();
// Arguments
expect(callback).toHaveBeenCalledWith('expected', 'args');
expect(callback).toHaveBeenLastCalledWith('last', 'call');
expect(callback).toHaveBeenNthCalledWith(2, 'second', 'call');
// Access raw data
expect(callback.mock.calls).toHaveLength(1);
expect(callback.mock.calls[0][0]).toBe('first argument');
expect(callback.mock.results[0].value).toBe('return value');
});
Mermaid Diagram (if helpful):
flowchart TD
A[Spy Characteristics] --> B[Records Calls]
A --> C[Can Delegate to Real Implementation]
A --> D[Flexible Assertions]
B --> B1[Call count]
B --> B2[Arguments]
B --> B3[Return values]
C --> C1[Full delegation<br/>jest.spyOn with no mock]
C --> C2[Partial override<br/>mockReturnValue]
C --> C3[Complete override<br/>mockImplementation]
D --> D1[Assert after execution]
D --> D2[Inspect during debugging]
D --> D3[Verify interaction patterns]
style A fill:#ffe1f5
style B fill:#e1f5ff
style C fill:#fff4e1
style D fill:#e1ffe1
References:
↑ Back to topWhat is a fake and when is it appropriate to use one?
The 30-Second Answer: A fake is a lightweight, working implementation of a dependency that's unsuitable for production but perfect for testing - like an in-memory database or a fake email service that stores messages in an array. Use fakes when you need realistic behavior without the complexity, speed, or infrastructure requirements of the real implementation.
The 2-Minute Answer (If They Want More): Fakes are unique among test doubles because they actually work - they have real business logic and maintain state, just like production code. The difference is that they take shortcuts that make them simpler, faster, or more suitable for testing. A classic example is using an in-memory database instead of PostgreSQL, or a fake payment gateway that always succeeds without making network calls.
I reach for fakes in specific scenarios where other test doubles fall short. When testing complex stateful interactions, stubs become unwieldy because you'd need to manually track and return different values for each call. Mocks become brittle because you'd be asserting on dozens of interactions. A fake lets you write tests that look almost like integration tests but run with unit test speed and reliability.
Fakes are particularly valuable for shared infrastructure components. If you build a good fake repository that behaves like your real repository, you can reuse it across hundreds of tests. This is more maintainable than creating individual stubs or mocks for each test. Many teams maintain "testing" implementations of key interfaces specifically for this purpose - fake repositories, fake message queues, fake file systems.
The main risk with fakes is that they can diverge from real implementations. If your fake database doesn't handle edge cases that the real database does, your tests might pass while production fails. To mitigate this, I use contract tests to verify that fakes and real implementations behave identically for the operations I care about. I also keep fakes simple - the more complex they get, the more they need their own tests, which defeats the purpose.
Code Example:
// FAKE DATABASE: In-memory implementation
class FakeUserRepository {
constructor() {
this.users = new Map();
this.nextId = 1;
}
async save(user) {
if (!user.id) {
user.id = this.nextId++;
}
this.users.set(user.id, { ...user });
return user;
}
async findById(id) {
return this.users.get(id) || null;
}
async findByEmail(email) {
return Array.from(this.users.values())
.find(u => u.email === email) || null;
}
async delete(id) {
return this.users.delete(id);
}
async clear() {
this.users.clear();
this.nextId = 1;
}
}
// Using the fake in tests
describe('UserService with Fake Repository', () => {
let userService;
let fakeRepo;
beforeEach(() => {
fakeRepo = new FakeUserRepository();
userService = new UserService(fakeRepo);
});
test('creates user and can retrieve by email', async () => {
const user = await userService.register({
email: 'alice@example.com',
name: 'Alice'
});
// The fake actually stores data and retrieves it
const found = await fakeRepo.findByEmail('alice@example.com');
expect(found.name).toBe('Alice');
expect(found.id).toBeDefined();
});
test('prevents duplicate email registration', async () => {
await userService.register({ email: 'bob@example.com', name: 'Bob' });
await expect(
userService.register({ email: 'bob@example.com', name: 'Bobby' })
).rejects.toThrow('Email already exists');
});
});
// FAKE EMAIL SERVICE: Captures emails without sending
class FakeEmailService {
constructor() {
this.sentEmails = [];
}
async send(to, subject, body) {
this.sentEmails.push({
to,
subject,
body,
sentAt: new Date()
});
return { success: true, messageId: `fake_${this.sentEmails.length}` };
}
async sendBulk(recipients, subject, body) {
recipients.forEach(to => {
this.sentEmails.push({ to, subject, body, sentAt: new Date() });
});
return { success: true, sent: recipients.length };
}
// Test helpers
getEmailsTo(address) {
return this.sentEmails.filter(e => e.to === address);
}
getEmailsWithSubject(subject) {
return this.sentEmails.filter(e => e.subject.includes(subject));
}
clear() {
this.sentEmails = [];
}
}
describe('NotificationService with Fake Email', () => {
let notificationService;
let fakeEmail;
beforeEach(() => {
fakeEmail = new FakeEmailService();
notificationService = new NotificationService(fakeEmail);
});
test('sends welcome email on user registration', async () => {
await notificationService.sendWelcome({
email: 'newuser@example.com',
name: 'New User'
});
const emails = fakeEmail.getEmailsTo('newuser@example.com');
expect(emails).toHaveLength(1);
expect(emails[0].subject).toContain('Welcome');
expect(emails[0].body).toContain('New User');
});
test('sends password reset to correct user', async () => {
await notificationService.sendPasswordReset('user1@example.com');
await notificationService.sendPasswordReset('user2@example.com');
const user1Emails = fakeEmail.getEmailsTo('user1@example.com');
expect(user1Emails).toHaveLength(1);
expect(user1Emails[0].subject).toContain('Password Reset');
});
});
// FAKE PAYMENT GATEWAY: Simulates different scenarios
class FakePaymentGateway {
constructor() {
this.transactions = [];
this.testMode = {
failNextCharge: false,
simulateNetworkError: false
};
}
async charge(amount, cardToken) {
if (this.testMode.simulateNetworkError) {
throw new Error('Network timeout');
}
if (this.testMode.failNextCharge) {
this.testMode.failNextCharge = false;
return { success: false, error: 'Insufficient funds' };
}
// Simulate special test card numbers
if (cardToken === 'tok_decline') {
return { success: false, error: 'Card declined' };
}
const transaction = {
id: `txn_${Date.now()}`,
amount,
cardToken,
status: 'succeeded',
createdAt: new Date()
};
this.transactions.push(transaction);
return { success: true, transaction };
}
async refund(transactionId, amount) {
const original = this.transactions.find(t => t.id === transactionId);
if (!original) {
throw new Error('Transaction not found');
}
const refund = {
id: `refund_${Date.now()}`,
originalTransaction: transactionId,
amount: amount || original.amount,
createdAt: new Date()
};
this.transactions.push(refund);
return { success: true, refund };
}
// Test control methods
setFailNextCharge(shouldFail = true) {
this.testMode.failNextCharge = shouldFail;
}
setNetworkError(shouldError = true) {
this.testMode.simulateNetworkError = shouldError;
}
getTransactions() {
return [...this.transactions];
}
clear() {
this.transactions = [];
this.testMode = { failNextCharge: false, simulateNetworkError: false };
}
}
describe('PaymentProcessor with Fake Gateway', () => {
let processor;
let fakeGateway;
beforeEach(() => {
fakeGateway = new FakePaymentGateway();
processor = new PaymentProcessor(fakeGateway);
});
test('processes successful payment', async () => {
const result = await processor.process(100, 'tok_valid');
expect(result.success).toBe(true);
expect(result.transaction.amount).toBe(100);
const transactions = fakeGateway.getTransactions();
expect(transactions).toHaveLength(1);
});
test('handles payment failure', async () => {
const result = await processor.process(100, 'tok_decline');
expect(result.success).toBe(false);
expect(result.error).toContain('declined');
});
test('retries on network error', async () => {
fakeGateway.setNetworkError(true);
const promise = processor.process(100, 'tok_valid');
// After short delay, disable network error to allow retry to succeed
setTimeout(() => fakeGateway.setNetworkError(false), 100);
const result = await promise;
expect(result.success).toBe(true);
});
});
// FAKE FILE SYSTEM: For testing file operations
class FakeFileSystem {
constructor() {
this.files = new Map();
}
async writeFile(path, content) {
this.files.set(path, {
content,
modifiedAt: new Date()
});
}
async readFile(path) {
const file = this.files.get(path);
if (!file) throw new Error(`File not found: ${path}`);
return file.content;
}
async exists(path) {
return this.files.has(path);
}
async deleteFile(path) {
return this.files.delete(path);
}
async listFiles(directory) {
return Array.from(this.files.keys())
.filter(path => path.startsWith(directory));
}
clear() {
this.files.clear();
}
}
describe('ReportGenerator with Fake FileSystem', () => {
let generator;
let fakeFs;
beforeEach(() => {
fakeFs = new FakeFileSystem();
generator = new ReportGenerator(fakeFs);
});
test('generates and saves report', async () => {
await generator.generate({ type: 'sales', period: 'Q1' });
const exists = await fakeFs.exists('/reports/sales-Q1.pdf');
expect(exists).toBe(true);
const content = await fakeFs.readFile('/reports/sales-Q1.pdf');
expect(content).toContain('Sales Report');
});
test('overwrites existing report', async () => {
await fakeFs.writeFile('/reports/sales-Q1.pdf', 'old content');
await generator.generate({ type: 'sales', period: 'Q1' });
const content = await fakeFs.readFile('/reports/sales-Q1.pdf');
expect(content).not.toBe('old content');
});
});
Mermaid Diagram (if helpful):
flowchart TD
A[When to Use Fakes] --> B{Need stateful<br/>behavior?}
A --> C{Complex<br/>interactions?}
A --> D{Reusable across<br/>many tests?}
B -->|Yes| E[Fake is ideal]
C -->|Yes| E
D -->|Yes| E
E --> F[Examples]
F --> F1[In-memory database]
F --> F2[Fake file system]
F --> F3[Fake email service]
F --> F4[Fake payment gateway]
G[Fake Advantages] --> G1[Fast execution]
G --> G2[Deterministic]
G --> G3[No infrastructure needed]
G --> G4[Reusable]
H[Fake Risks] --> H1[May diverge from<br/>real implementation]
H --> H2[Requires maintenance]
H --> H3[Can become complex]
style E fill:#e1ffe1
style G fill:#e1f5ff
style H fill:#ffe1e1
References:
- Martin Fowler - Test Double
- xUnit Test Patterns - Fake Object
- Growing Object-Oriented Software - Test Doubles
Code Coverage
What is code coverage and what types exist (line, branch, path)?
The 30-Second Answer: Code coverage measures how much of your code is executed during testing. The main types are line coverage (which lines run), branch coverage (which if/else paths are taken), and path coverage (which combinations of branches execute through the code).
The 2-Minute Answer (If They Want More): Code coverage is a metric that tells you what percentage of your codebase is actually executed when your tests run. It's a useful indicator of test completeness, but not a guarantee of test quality.
Line coverage (or statement coverage) is the simplest - it tracks whether each line of code was executed at least once. Branch coverage goes deeper by tracking whether both the true and false branches of every conditional statement were tested. For example, if you have an if-else statement, branch coverage requires tests for both paths.
Path coverage is the most comprehensive but also the most complex. It measures whether all possible paths through the code have been executed. In code with multiple conditionals, the number of paths grows exponentially. For instance, three independent if statements create eight possible paths.
Function coverage tracks whether each function was called, while condition coverage examines whether each boolean sub-expression was evaluated to both true and false. Most teams focus on line and branch coverage as a practical balance between thoroughness and maintainability.
Code Example:
// Example function to demonstrate different coverage types
function processOrder(order, isPremium, hasDiscount) {
// Line 1: Always executed if function is called (line coverage)
let total = order.price;
// Line 2-3: Branch coverage - need tests for both true/false
if (isPremium) {
total *= 0.9; // 10% premium discount
}
// Line 4-6: Path coverage - combines with previous branch
if (hasDiscount) {
total *= 0.95; // Additional 5% discount
}
return total;
}
// Test cases for different coverage levels:
// Test 1: Achieves 100% line coverage
test('calculates total for premium with discount', () => {
expect(processOrder({price: 100}, true, true)).toBe(85.5);
});
// Lines covered: all | Branches: 2/2 true, 0/2 false | Paths: 1/4
// Test 2: Add this for 100% branch coverage
test('calculates total for non-premium without discount', () => {
expect(processOrder({price: 100}, false, false)).toBe(100);
});
// Now branches: 2/2 true, 2/2 false | Paths: 2/4
// Tests 3-4: Add these for 100% path coverage
test('premium without discount', () => {
expect(processOrder({price: 100}, true, false)).toBe(90);
});
test('non-premium with discount', () => {
expect(processOrder({price: 100}, false, true)).toBe(95);
});
// Now paths: 4/4 (100%)
// Coverage report would show:
// Line Coverage: 100% (all lines executed)
// Branch Coverage: 100% (all if/else paths taken)
// Path Coverage: 100% (all combinations tested)
Mermaid Diagram (if helpful):
flowchart TD
A[Start: processOrder] --> B[total = order.price]
B --> C{isPremium?}
C -->|true| D[total *= 0.9]
C -->|false| E[Skip premium discount]
D --> F{hasDiscount?}
E --> F
F -->|true| G[total *= 0.95]
F -->|false| H[Skip discount]
G --> I[Return total]
H --> I
style C fill:#ffeb3b
style F fill:#ffeb3b
style D fill:#4caf50
style E fill:#4caf50
style G fill:#2196f3
style H fill:#2196f3
classDef pathNode fill:#f9f,stroke:#333,stroke-width:2px
References:
↑ Back to topWhat is a good code coverage percentage to aim for?
The 30-Second Answer: Most teams aim for 70-90% code coverage, with 80% being a common target. The right percentage depends on your project - critical systems need higher coverage, while 100% coverage is often impractical and doesn't guarantee bug-free code.
The 2-Minute Answer (If They Want More): There's no one-size-fits-all answer, but industry standards typically fall between 70-90% coverage. Many successful teams target 80% as a balanced goal that ensures good test coverage without obsessing over every single line.
The appropriate coverage target varies by project type. Financial systems, healthcare applications, or safety-critical software should aim for 90%+ coverage due to the high cost of failures. Standard business applications often do well with 70-80%. Experimental or rapidly changing codebases might accept lower coverage initially, prioritizing coverage for stable, core functionality.
It's crucial to understand that coverage percentage is a metric, not a goal. 100% coverage doesn't mean zero bugs - it only means every line was executed, not that every scenario was tested or that the tests are meaningful. I've seen codebases with 95% coverage full of bugs because the tests didn't verify correct behavior.
The better approach is to focus coverage on critical paths: authentication, payment processing, data validation, and core business logic should have very high coverage (90%+), while UI rendering code, simple getters/setters, or configuration files might not need the same attention. Set minimum thresholds to prevent coverage from dropping, but use coverage as a tool to find untested code, not as a measure of quality.
Code Example:
// jest.config.js - Setting coverage thresholds
module.exports = {
collectCoverageFrom: [
'src/**/*.{js,jsx,ts,tsx}',
'!src/**/*.d.ts',
'!src/index.js', // Entry files often don't need high coverage
],
coverageThresholds: {
global: {
branches: 75,
functions: 80,
lines: 80,
statements: 80,
},
// Higher thresholds for critical modules
'./src/payment/*.js': {
branches: 90,
functions: 95,
lines: 95,
statements: 95,
},
'./src/auth/*.js': {
branches: 90,
functions: 95,
lines: 95,
statements: 95,
},
// Lower thresholds acceptable for UI components
'./src/components/ui/*.js': {
branches: 60,
functions: 70,
lines: 70,
statements: 70,
},
},
};
// Example coverage report interpretation
/*
----------------------|---------|----------|---------|---------|
File | % Stmts | % Branch | % Funcs | % Lines |
----------------------|---------|----------|---------|---------|
All files | 82.14 | 76.32 | 85.71 | 82.05 |
src/ | 100 | 100 | 100 | 100 |
index.js | 100 | 100 | 100 | 100 |
src/auth/ | 96.55 | 91.67 | 100 | 96.43 |
login.js | 100 | 100 | 100 | 100 |
session.js | 90 | 80 | 100 | 89.47 | ⚠️ Review needed
src/payment/ | 94.23 | 88.89 | 100 | 94.12 |
checkout.js | 100 | 100 | 100 | 100 |
stripe.js | 88.89 | 77.78 | 100 | 88.24 | ⚠️ Below target
src/components/ui/ | 68.42 | 58.33 | 71.43 | 68.18 |
Button.js | 75 | 50 | 80 | 75 | âś“ Acceptable for UI
Modal.js | 62.5 | 66.67 | 62.5 | 61.54 | âś“ Acceptable for UI
----------------------|---------|----------|---------|---------|
Action items:
1. Investigate session.js - missing edge cases?
2. Add tests for stripe.js error handling
3. UI components meet relaxed thresholds
*/
// Package.json scripts for coverage
{
"scripts": {
"test": "jest",
"test:coverage": "jest --coverage",
"test:watch": "jest --watch",
"test:ci": "jest --coverage --ci --maxWorkers=2",
// Fail CI if coverage drops below thresholds
"test:threshold": "jest --coverage --coverageThreshold='{\"global\":{\"lines\":80}}'"
}
}
Mermaid Diagram (if helpful):
graph LR
A[Code Coverage Goals] --> B[Critical Systems<br/>90-95%]
A --> C[Business Logic<br/>80-90%]
A --> D[Standard Features<br/>70-80%]
A --> E[UI/Presentation<br/>60-70%]
B --> F[Payment Processing]
B --> G[Authentication]
B --> H[Security Features]
C --> I[Core Business Rules]
C --> J[Data Validation]
C --> K[API Endpoints]
D --> L[Utilities]
D --> M[Services]
D --> N[Controllers]
E --> O[Components]
E --> P[Styling Logic]
E --> Q[Templates]
style B fill:#f44336,color:#fff
style C fill:#ff9800,color:#fff
style D fill:#4caf50,color:#fff
style E fill:#2196f3,color:#fff
References:
- Google Testing Blog - Code Coverage Best Practices
- Thoughtworks - On the Effectiveness of Coverage Testing
- Stack Overflow Developer Survey - Testing Practices
Test Quality and Maintainability
What is the test pyramid and why is it important?
The 30-Second Answer: The test pyramid is a testing strategy with many fast unit tests at the base, fewer integration tests in the middle, and minimal slow end-to-end tests at the top. It's important because it balances comprehensive coverage with fast feedback and maintainable test suites.
The 2-Minute Answer (If They Want More): The test pyramid, introduced by Mike Cohn, represents the ideal distribution of different test types in a healthy test suite. The base consists of unit tests—fast, isolated tests of individual functions or components. The middle layer contains integration tests that verify how multiple components work together. The top has end-to-end (E2E) tests that validate entire user workflows through the UI.
This structure is important for several reasons. Unit tests are fast (milliseconds), cheap to write and maintain, and pinpoint exactly what broke when they fail. They should make up 70-80% of your tests. Integration tests are slower (seconds) but catch issues in component interactions and are crucial for verifying system behavior. They should be 15-20% of tests. E2E tests are slowest (minutes), most expensive to maintain, and most brittle, but they validate that everything works from the user's perspective. They should be only 5-10% of tests.
The pyramid shape is critical because it inverts the cost-to-value ratio. If you have mostly E2E tests (an "ice cream cone" anti-pattern), your test suite becomes slow, flaky, and expensive to maintain. Developers stop running tests frequently, reducing their value. The pyramid ensures fast feedback during development while still catching integration issues.
Modern variations include the "testing trophy" which emphasizes integration tests slightly more, but the principle remains: build on a foundation of fast, focused tests rather than relying on slow, broad tests.
Code Example:
// UNIT TESTS (70-80% of tests) - Fast, isolated, focused
// Test individual functions/components in isolation
describe('calculateDiscount', () => {
it('applies 10% discount for premium users', () => {
const result = calculateDiscount(100, { isPremium: true });
expect(result).toBe(90);
});
it('applies no discount for regular users', () => {
const result = calculateDiscount(100, { isPremium: false });
expect(result).toBe(100);
});
it('caps discount at 50%', () => {
const result = calculateDiscount(100, { isPremium: true, customDiscount: 0.8 });
expect(result).toBe(50);
});
});
// Mock external dependencies
describe('UserService', () => {
it('creates user with hashed password', async () => {
const mockHasher = jest.fn().mockResolvedValue('hashed123');
const service = new UserService({ hasher: mockHasher });
await service.createUser('test@example.com', 'password');
expect(mockHasher).toHaveBeenCalledWith('password');
});
});
// INTEGRATION TESTS (15-20% of tests) - Test component interactions
// Verify multiple units work together correctly
describe('OrderCheckout Integration', () => {
let database;
let paymentService;
let orderService;
beforeEach(async () => {
database = await createTestDatabase();
paymentService = new PaymentService(database);
orderService = new OrderService(database, paymentService);
});
it('processes complete order flow', async () => {
// Real database, real services, mock only external APIs
const mockPaymentGateway = jest.fn().mockResolvedValue({
transactionId: 'tx123',
status: 'success'
});
paymentService.gateway = mockPaymentGateway;
const order = await orderService.createOrder({
userId: 'user123',
items: [{ id: 'item1', quantity: 2 }],
total: 50
});
const result = await orderService.checkout(order.id, {
cardNumber: '4111111111111111'
});
// Verify integration between services and database
expect(result.status).toBe('completed');
expect(mockPaymentGateway).toHaveBeenCalled();
const dbOrder = await database.orders.findById(order.id);
expect(dbOrder.status).toBe('completed');
expect(dbOrder.transactionId).toBe('tx123');
});
});
// E2E TESTS (5-10% of tests) - Slow, test entire user workflows
// Verify complete user journeys through the UI
describe('E2E: User Purchase Flow', () => {
it('completes full purchase from browse to confirmation', async () => {
// This test exercises the entire system: UI, API, database, payment
await page.goto('http://localhost:3000/products');
// Browse products
await page.click('[data-testid="product-card-1"]');
await page.waitForSelector('[data-testid="product-details"]');
// Add to cart
await page.click('[data-testid="add-to-cart"]');
await page.click('[data-testid="cart-icon"]');
// Checkout
await page.click('[data-testid="checkout-button"]');
await page.fill('[data-testid="card-number"]', '4111111111111111');
await page.fill('[data-testid="cvv"]', '123');
await page.click('[data-testid="place-order"]');
// Verify confirmation
await page.waitForSelector('[data-testid="order-confirmation"]');
const orderNumber = await page.textContent('[data-testid="order-number"]');
expect(orderNumber).toMatch(/^ORD-\d+$/);
});
});
// ❌ ANTI-PATTERN: Ice cream cone (inverted pyramid)
// Too many slow E2E tests, few unit tests
describe('E2E Tests', () => {
it('tests every single edge case through UI', async () => {
// Testing discount calculations through full UI flow - SLOW!
});
it('tests validation through UI', async () => {
// Testing input validation by filling forms - SLOW!
});
// 100+ E2E tests... test suite takes hours
});
// âś… GOOD: Pyramid distribution
// - 200 unit tests (run in 2 seconds)
// - 40 integration tests (run in 20 seconds)
// - 10 E2E tests (run in 5 minutes)
// Total: 250 tests, ~6 minutes
Mermaid Diagram (if helpful):
graph TD
subgraph "Test Pyramid"
A["E2E Tests<br/>5-10%<br/>Slowest, Most Expensive<br/>Full User Workflows"]
B["Integration Tests<br/>15-20%<br/>Component Interactions<br/>Service Layer"]
C["Unit Tests<br/>70-80%<br/>Fastest, Cheapest<br/>Individual Functions"]
end
A --> B
B --> C
style A fill:#ff6b6b
style B fill:#ffd93d
style C fill:#6bcf7f
subgraph "Characteristics"
D["Speed: Slow → Fast<br/>Cost: High → Low<br/>Scope: Wide → Narrow<br/>Quantity: Few → Many"]
end
subgraph "Anti-Pattern: Ice Cream Cone"
E["Many E2E Tests<br/>⚠️ Slow Suite"]
F["Few Integration Tests"]
G["Few Unit Tests<br/>⚠️ Poor Coverage"]
end
E --> F
F --> G
style E fill:#ff6b6b
style F fill:#ffd93d
style G fill:#ff9999
References:
↑ Back to topMocking and Dependencies
What is dependency injection and how does it help testing?
The 30-Second Answer: Dependency injection is a design pattern where dependencies are passed into a class or function from the outside, rather than being created internally. This makes code testable by allowing you to inject mock dependencies during tests while using real ones in production.
The 2-Minute Answer (If They Want More):
Dependency injection (DI) inverts the control of dependency creation. Instead of a class instantiating its own dependencies with new, it receives them through constructor parameters, method parameters, or property setters. This simple change has profound implications for testability.
Without DI, testing is difficult because the class tightly couples to concrete implementations. You can't easily substitute a database with a mock. With DI, the class depends on abstractions (interfaces or protocols), and you inject whatever implementation you need - real or mock.
There are three main DI patterns: constructor injection (dependencies passed when object is created), setter injection (dependencies set after creation), and method injection (dependencies passed to individual methods). Constructor injection is generally preferred because it makes dependencies explicit and ensures objects are always in a valid state.
DI also promotes the Dependency Inversion Principle - depending on abstractions rather than concrete implementations. This makes code more modular, flexible, and testable. Many frameworks provide DI containers that automate dependency wiring, but the pattern itself is simple and doesn't require any framework.
Code Example:
// ❌ Bad: Hard to test - dependencies created internally
class OrderProcessor {
constructor() {
this.paymentGateway = new StripePaymentGateway(); // Tightly coupled
this.emailService = new SendGridEmailService(); // Cannot mock
this.database = new PostgresDatabase(); // Will hit real DB
}
async processOrder(order) {
const payment = await this.paymentGateway.charge(order.total);
await this.database.save(order);
await this.emailService.send(order.customerEmail, 'Order confirmed');
return payment;
}
}
// Testing is impossible without hitting real Stripe, SendGrid, and Postgres!
// âś… Good: Constructor Injection - testable
class OrderProcessor {
constructor(paymentGateway, emailService, database) {
this.paymentGateway = paymentGateway;
this.emailService = emailService;
this.database = database;
}
async processOrder(order) {
const payment = await this.paymentGateway.charge(order.total);
await this.database.save(order);
await this.emailService.send(order.customerEmail, 'Order confirmed');
return payment;
}
}
// Production usage - inject real dependencies
const processor = new OrderProcessor(
new StripePaymentGateway(),
new SendGridEmailService(),
new PostgresDatabase()
);
// Test usage - inject mocks
describe('OrderProcessor', () => {
test('processes order successfully', async () => {
const mockPaymentGateway = {
charge: jest.fn().mockResolvedValue({ id: 'pay_123', status: 'success' })
};
const mockEmailService = {
send: jest.fn().mockResolvedValue(true)
};
const mockDatabase = {
save: jest.fn().mockResolvedValue(true)
};
const processor = new OrderProcessor(
mockPaymentGateway,
mockEmailService,
mockDatabase
);
const order = {
id: 'order_1',
total: 99.99,
customerEmail: 'customer@example.com'
};
const result = await processor.processOrder(order);
expect(mockPaymentGateway.charge).toHaveBeenCalledWith(99.99);
expect(mockDatabase.save).toHaveBeenCalledWith(order);
expect(mockEmailService.send).toHaveBeenCalledWith(
'customer@example.com',
'Order confirmed'
);
expect(result).toEqual({ id: 'pay_123', status: 'success' });
});
});
// âś… Method Injection - for occasional dependencies
class ReportGenerator {
generateReport(data, formatter) {
const processed = this.processData(data);
return formatter.format(processed); // Formatter injected per method call
}
processData(data) {
return data.map(item => ({ ...item, processed: true }));
}
}
describe('ReportGenerator', () => {
test('formats report with injected formatter', () => {
const generator = new ReportGenerator();
const mockFormatter = {
format: jest.fn().mockReturnValue('<html>Report</html>')
};
const result = generator.generateReport([{ id: 1 }], mockFormatter);
expect(mockFormatter.format).toHaveBeenCalledWith([
{ id: 1, processed: true }
]);
expect(result).toBe('<html>Report</html>');
});
});
Mermaid Diagram (if helpful):
flowchart LR
subgraph Production
A[Main App] -->|creates| B[Real Dependencies]
B -->|injects| C[OrderProcessor]
end
subgraph Testing
D[Test] -->|creates| E[Mock Dependencies]
E -->|injects| F[OrderProcessor]
end
style B fill:#FFB6C6
style E fill:#90EE90
style C fill:#87CEEB
style F fill:#87CEEB
References:
↑ Back to topHow do you handle external dependencies in unit tests?
The 30-Second Answer: I isolate the unit under test by replacing external dependencies with mocks, stubs, or fakes. This ensures tests are fast, deterministic, and focused on the behavior of the single unit being tested, not the entire system.
The 2-Minute Answer (If They Want More): External dependencies like databases, APIs, file systems, or third-party services introduce complexity, unpredictability, and slowness to unit tests. To handle them, I use test doubles - simplified versions that mimic the behavior of real dependencies.
The strategy depends on the dependency type. For simple value returns, I use stubs. For verifying interactions, I use mocks. For complex behavior simulation, I might use fakes (lightweight implementations). The key is to maintain the interface contract while removing the actual external interaction.
I typically combine dependency injection with mocking frameworks like Jest, Sinon, or TestDouble. This allows me to inject mock implementations during tests while using real implementations in production. I also create reusable test fixtures for common dependency configurations, making tests more maintainable.
The golden rule is that unit tests should never hit real external systems. If a test needs a database, it should use an in-memory fake or mock. If it needs an API, it should use a mock HTTP client. This keeps tests fast (milliseconds, not seconds), reliable (no network failures), and repeatable (same results every time).
Code Example:
// Service with external dependencies
class UserService {
constructor(database, emailClient, logger) {
this.db = database;
this.emailClient = emailClient;
this.logger = logger;
}
async createUser(userData) {
this.logger.info('Creating user', userData);
const user = await this.db.insert('users', userData);
await this.emailClient.sendWelcomeEmail(user.email);
return user;
}
}
// Unit test with mocked dependencies
describe('UserService', () => {
let mockDatabase;
let mockEmailClient;
let mockLogger;
let userService;
beforeEach(() => {
// Create mocks for each dependency
mockDatabase = {
insert: jest.fn().mockResolvedValue({ id: 1, email: 'test@example.com' })
};
mockEmailClient = {
sendWelcomeEmail: jest.fn().mockResolvedValue(true)
};
mockLogger = {
info: jest.fn(),
error: jest.fn()
};
// Inject mocks into service
userService = new UserService(mockDatabase, mockEmailClient, mockLogger);
});
test('creates user and sends welcome email', async () => {
const userData = { email: 'test@example.com', name: 'Test User' };
const result = await userService.createUser(userData);
// Verify database interaction
expect(mockDatabase.insert).toHaveBeenCalledWith('users', userData);
// Verify email was sent
expect(mockEmailClient.sendWelcomeEmail).toHaveBeenCalledWith('test@example.com');
// Verify logging occurred
expect(mockLogger.info).toHaveBeenCalledWith('Creating user', userData);
// Verify return value
expect(result).toEqual({ id: 1, email: 'test@example.com' });
});
test('handles database errors gracefully', async () => {
mockDatabase.insert.mockRejectedValue(new Error('DB connection failed'));
await expect(userService.createUser({ email: 'test@example.com' }))
.rejects.toThrow('DB connection failed');
// Email should not be sent if database fails
expect(mockEmailClient.sendWelcomeEmail).not.toHaveBeenCalled();
});
});
Mermaid Diagram (if helpful):
flowchart TD
A[Unit Test] --> B[System Under Test]
B --> C[Mock Database]
B --> D[Mock Email Client]
B --> E[Mock Logger]
C -.->|replaces| F[Real Database]
D -.->|replaces| G[Real Email Service]
E -.->|replaces| H[Real Logger]
style C fill:#90EE90
style D fill:#90EE90
style E fill:#90EE90
style F fill:#FFB6C6
style G fill:#FFB6C6
style H fill:#FFB6C6
References:
↑ Back to topTest Organization
What is the one assertion per test rule and when should you follow it?
The 30-Second Answer: The one assertion per test rule suggests each test should verify a single behavior with one assertion. However, I treat it as a guideline rather than a strict rule - what matters is testing one logical concept, which may require multiple assertions to fully verify.
The 2-Minute Answer (If They Want More): The one assertion per test rule aims to make tests focused and failures easy to diagnose. When a test with multiple assertions fails, you might not immediately know which assertion caused the failure, though modern testing frameworks have improved this with clear error messages.
The real principle is "one logical concept per test" rather than literally one assertion. For example, when testing that a function returns a user object, I might assert both that the object exists and that it has the expected properties. These multiple assertions verify the same logical concept: "returns a complete user object".
I follow the rule strictly when assertions test different behaviors. If one test checks both "creates user" and "sends email notification", those should be separate tests because they're distinct behaviors that could fail independently.
However, I use multiple assertions when they verify different aspects of the same behavior. Testing a complex object often requires several assertions to ensure completeness. I also use multiple assertions for setup verification - confirming test preconditions before the main assertion.
The key question is: if this test fails, will I immediately understand what went wrong? If multiple assertions muddy that clarity, split the test. If they collectively verify one clear behavior, keep them together.
Code Example:
// Good: Multiple assertions verifying one logical concept
describe('UserService.createUser', () => {
it('should return complete user object with hashed password', async () => {
const userData = { email: 'test@example.com', password: 'secret123' };
const user = await userService.createUser(userData);
// All assertions verify the same concept: "returns complete user"
expect(user).toBeDefined();
expect(user.id).toBeDefined();
expect(user.email).toBe('test@example.com');
expect(user.password).not.toBe('secret123'); // password is hashed
expect(user.createdAt).toBeInstanceOf(Date);
});
});
// Bad: Multiple unrelated assertions (different behaviors)
it('should create user and send notification', async () => {
const user = await userService.createUser(userData);
expect(user).toBeDefined(); // Testing user creation
const emails = await emailService.getSentEmails();
expect(emails).toHaveLength(1); // Testing email notification
});
// Better: Split into focused tests
describe('UserService.createUser', () => {
it('should create user with valid data', async () => {
const user = await userService.createUser(userData);
expect(user).toBeDefined();
expect(user.email).toBe('test@example.com');
});
it('should send welcome email after user creation', async () => {
await userService.createUser(userData);
const emails = await emailService.getSentEmails();
expect(emails).toHaveLength(1);
expect(emails[0].to).toBe('test@example.com');
});
});
// Good: Using multiple assertions for complex object validation
it('should transform API response to user model', () => {
const apiResponse = {
user_id: 123,
email_address: 'test@example.com',
first_name: 'John',
last_name: 'Doe'
};
const user = transformToUserModel(apiResponse);
// All assertions verify correct transformation
expect(user.id).toBe(123);
expect(user.email).toBe('test@example.com');
expect(user.firstName).toBe('John');
expect(user.lastName).toBe('Doe');
});
Mermaid Diagram (if helpful):
flowchart TD
A[Multiple Assertions in Test] --> B{Do they verify the same logical concept?}
B -->|Yes| C{Will failure be clear?}
B -->|No| D[Split into separate tests]
C -->|Yes| E[Keep together - valid]
C -->|No| D
D --> F[One concept per test]
E --> G[Focused, maintainable tests]
F --> G
References:
↑ Back to topTesting Edge Cases
What is property-based testing and when would you use it?
The 30-Second Answer: Property-based testing generates hundreds of random inputs to verify that certain properties (invariants) always hold true for a function. Instead of writing specific test cases, I define properties like "output should always be sorted" or "function should never throw" and let the framework test with diverse inputs to find edge cases I might miss.
The 2-Minute Answer (If They Want More): Property-based testing is a complementary approach to example-based testing where you describe what should always be true about your code rather than testing specific examples. Libraries like fast-check (JavaScript) or Hypothesis (Python) generate hundreds of random test cases based on your property definitions, often finding edge cases that humans wouldn't think to test.
I use property-based testing when I can express invariants about my code - properties that should always hold regardless of input. Common properties include: reversibility (encoding then decoding returns original), idempotence (applying twice equals applying once), commutativity (order doesn't matter), and structural properties (output is always sorted, always positive, always valid JSON).
Property-based testing excels at finding edge cases. The framework generates random inputs, and when it finds a failing case, it "shrinks" the input to find the minimal failing example. For instance, if your function fails with a 1000-element array, the framework will reduce it to the smallest array that still fails, making debugging easier.
I use property-based testing for algorithmic code, parsers, encoders/decoders, mathematical functions, and any code with clear invariants. It's particularly valuable for validating refactors - if properties hold before and after, the refactor likely preserved behavior. However, it doesn't replace example-based tests; I use both together for comprehensive coverage.
Code Example:
const fc = require('fast-check');
describe('property-based testing examples', () => {
// Property: Reversibility (encode/decode round-trip)
describe('base64 encoding', () => {
test('encoding then decoding returns original string', () => {
fc.assert(
fc.property(fc.string(), (str) => {
const encoded = btoa(str);
const decoded = atob(encoded);
expect(decoded).toBe(str);
})
);
});
});
// Property: Idempotence (applying operation twice equals once)
describe('sorting', () => {
test('sorting an array twice produces same result as sorting once', () => {
fc.assert(
fc.property(fc.array(fc.integer()), (arr) => {
const sortedOnce = [...arr].sort((a, b) => a - b);
const sortedTwice = [...sortedOnce].sort((a, b) => a - b);
expect(sortedTwice).toEqual(sortedOnce);
})
);
});
test('sorted array is always ordered', () => {
fc.assert(
fc.property(fc.array(fc.integer()), (arr) => {
const sorted = [...arr].sort((a, b) => a - b);
// Verify every element is <= next element
for (let i = 0; i < sorted.length - 1; i++) {
expect(sorted[i]).toBeLessThanOrEqual(sorted[i + 1]);
}
})
);
});
});
// Property: Commutativity (order of operations doesn't matter)
describe('set operations', () => {
test('union is commutative', () => {
fc.assert(
fc.property(
fc.array(fc.integer()),
fc.array(fc.integer()),
(arr1, arr2) => {
const set1 = new Set(arr1);
const set2 = new Set(arr2);
const union1 = new Set([...set1, ...set2]);
const union2 = new Set([...set2, ...set1]);
expect([...union1].sort()).toEqual([...union2].sort());
}
)
);
});
});
// Property: Structural invariants
describe('addToCart', () => {
test('total price is always sum of item prices', () => {
fc.assert(
fc.property(
fc.array(fc.record({
id: fc.string(),
price: fc.double({ min: 0, max: 10000 }),
quantity: fc.integer({ min: 1, max: 100 })
})),
(items) => {
const cart = createCart();
items.forEach(item => cart.addItem(item));
const expectedTotal = items.reduce(
(sum, item) => sum + (item.price * item.quantity),
0
);
expect(cart.getTotal()).toBeCloseTo(expectedTotal, 2);
}
)
);
});
test('cart never contains negative quantities', () => {
fc.assert(
fc.property(
fc.array(fc.record({
id: fc.string(),
price: fc.double({ min: 0, max: 1000 }),
quantity: fc.integer({ min: 1, max: 100 })
})),
(items) => {
const cart = createCart();
items.forEach(item => cart.addItem(item));
cart.getItems().forEach(item => {
expect(item.quantity).toBeGreaterThan(0);
});
}
)
);
});
});
// Property: Metamorphic relations
describe('search function', () => {
test('searching for longer string returns subset of results', () => {
fc.assert(
fc.property(
fc.array(fc.string()),
fc.string({ minLength: 1 }),
(documents, query) => {
const results1 = searchDocuments(documents, query);
const results2 = searchDocuments(documents, query + 'x');
// Longer query should return fewer or equal results
expect(results2.length).toBeLessThanOrEqual(results1.length);
// All results from longer query should be in shorter query results
results2.forEach(result => {
expect(results1).toContainEqual(result);
});
}
)
);
});
});
// Property: Error handling
describe('divide function', () => {
test('never throws for any numeric inputs', () => {
fc.assert(
fc.property(
fc.double(),
fc.double(),
(a, b) => {
// Should return Infinity or NaN instead of throwing
expect(() => divide(a, b)).not.toThrow();
}
)
);
});
test('division by non-zero is reversible', () => {
fc.assert(
fc.property(
fc.double({ noNaN: true }),
fc.double({ min: 0.001, max: 1000 }), // Avoid zero
(a, b) => {
const result = divide(a, b);
const reversed = result * b;
expect(reversed).toBeCloseTo(a, 5);
}
)
);
});
});
// Property: Comparing implementations
describe('optimized sort vs standard sort', () => {
test('optimized sort produces same results as standard sort', () => {
fc.assert(
fc.property(fc.array(fc.integer()), (arr) => {
const standardResult = standardSort([...arr]);
const optimizedResult = optimizedSort([...arr]);
expect(optimizedResult).toEqual(standardResult);
})
);
});
});
// Custom generator for domain-specific testing
describe('email validation', () => {
test('valid emails always pass validation', () => {
// Custom email generator
const emailArbitrary = fc.tuple(
fc.stringOf(fc.char(), { minLength: 1, maxLength: 64 }),
fc.stringOf(fc.char(), { minLength: 1, maxLength: 255 })
).map(([local, domain]) => `${local}@${domain}.com`);
fc.assert(
fc.property(emailArbitrary, (email) => {
expect(isValidEmail(email)).toBe(true);
}),
{ numRuns: 1000 } // Run 1000 random tests
);
});
});
// Shrinking example - finding minimal failing case
describe('shrinking demonstration', () => {
test('demonstrates how fast-check shrinks failing inputs', () => {
try {
fc.assert(
fc.property(fc.array(fc.integer()), (arr) => {
// This will fail for arrays containing 42
expect(arr).not.toContain(42);
})
);
} catch (error) {
// fast-check will shrink to smallest failing case: [42]
console.log('Minimal failing case found:', error.counterexample);
}
});
});
});
// Example implementations
function divide(a, b) {
return a / b; // Returns Infinity for division by zero
}
function searchDocuments(documents, query) {
return documents.filter(doc =>
doc.toLowerCase().includes(query.toLowerCase())
);
}
function createCart() {
const items = [];
return {
addItem(item) {
items.push(item);
},
getItems() {
return items;
},
getTotal() {
return items.reduce((sum, item) =>
sum + (item.price * item.quantity), 0
);
}
};
}
function isValidEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
Mermaid Diagram (if helpful):
flowchart TD
A[Property-Based Testing] --> B[Define Properties/Invariants]
B --> C[Reversibility]
B --> D[Idempotence]
B --> E[Commutativity]
B --> F[Structural Invariants]
C --> G[encode then decode = original]
D --> H[f of f of x = f of x]
E --> I[a op b = b op a]
F --> J[output always has required structure]
G --> K[Generate Random Inputs]
H --> K
I --> K
J --> K
K --> L[Run Hundreds of Tests]
L --> M{All Pass?}
M -->|Yes| N[Property Holds]
M -->|No| O[Shrink to Minimal Failing Case]
O --> P[Debug with Minimal Example]
P --> Q[Fix Implementation]
Q --> K
N --> R[High Confidence in Invariants]
References:
- fast-check Documentation
- Introduction to Property-Based Testing
- Property-Based Testing with JavaScript - Hypothesis
- QuickCheck: A Lightweight Tool for Random Testing
Advanced Unit Testing
What is mutation testing and how does it improve test quality?
The 30-Second Answer: Mutation testing validates test quality by deliberately introducing bugs (mutations) into code and checking if tests catch them. If a mutation causes tests to fail, the tests are effective; if tests still pass with mutated code, there's a gap in test coverage. This reveals weaknesses that code coverage metrics miss.
The 2-Minute Answer (If They Want More): Traditional code coverage measures which lines of code are executed during tests, but doesn't measure whether tests actually verify correct behavior. You can have 100% code coverage with worthless tests that don't make meaningful assertions. Mutation testing solves this by measuring the quality of your tests, not just their reach.
Mutation testing works by making small, systematic changes to your source code - called mutants. These changes simulate common programming mistakes: changing operators (+ to -, == to !=), modifying conditions (< to <=), removing statements, or changing constants. Each mutation represents a potential bug. The mutation testing tool runs your test suite against each mutant.
A mutant is "killed" when at least one test fails with the mutated code, proving your tests would catch that bug. A mutant "survives" when all tests still pass despite the code being wrong, indicating a gap in your testing. The mutation score is the percentage of killed mutants - higher scores mean better test quality.
Mutation testing is computationally expensive because it runs your entire test suite multiple times (once per mutant). For large codebases, this can take hours. Tools like Stryker use optimizations like selecting relevant tests for each mutant, running in parallel, and incremental mutation testing. Despite the cost, mutation testing provides insights that no other metric can, revealing exactly where your tests are weak and what kinds of bugs they wouldn't catch.
Code Example:
// Original function
function calculateDiscount(price, customerType) {
if (price > 100 && customerType === 'premium') {
return price * 0.2;
}
return price * 0.1;
}
// Mutation 1: Change operator (> to >=)
function calculateDiscount(price, customerType) {
if (price >= 100 && customerType === 'premium') { // MUTANT
return price * 0.2;
}
return price * 0.1;
}
// Mutation 2: Change equality operator (=== to !==)
function calculateDiscount(price, customerType) {
if (price > 100 && customerType !== 'premium') { // MUTANT
return price * 0.2;
}
return price * 0.1;
}
// Mutation 3: Change constant (0.2 to 0.3)
function calculateDiscount(price, customerType) {
if (price > 100 && customerType === 'premium') {
return price * 0.3; // MUTANT
}
return price * 0.1;
}
// Weak tests - don't catch boundary conditions
describe('calculateDiscount - weak tests', () => {
test('calculates premium discount', () => {
expect(calculateDiscount(200, 'premium')).toBe(40);
// Mutation 1 survives - doesn't test boundary at 100
});
test('calculates regular discount', () => {
expect(calculateDiscount(50, 'regular')).toBe(5);
// Mutation 2 survives - doesn't test premium logic with wrong type
});
// Code coverage: 100%
// Mutation score: ~33% (many mutants survive)
});
// Strong tests - kill all mutants
describe('calculateDiscount - strong tests', () => {
describe('premium customers', () => {
test('gets 20% discount when price over 100', () => {
expect(calculateDiscount(150, 'premium')).toBe(30);
// Kills mutation 3 (wrong percentage)
});
test('gets 10% discount when price exactly 100', () => {
expect(calculateDiscount(100, 'premium')).toBe(10);
// Kills mutation 1 (>= instead of >)
});
test('gets 10% discount when price under 100', () => {
expect(calculateDiscount(99, 'premium')).toBe(9.9);
});
});
describe('regular customers', () => {
test('always gets 10% discount', () => {
expect(calculateDiscount(150, 'regular')).toBe(15);
// Kills mutation 2 (!== instead of ===)
});
test('gets 10% discount at boundary', () => {
expect(calculateDiscount(100, 'regular')).toBe(10);
});
});
describe('edge cases', () => {
test('handles zero price', () => {
expect(calculateDiscount(0, 'premium')).toBe(0);
});
test('handles undefined customer type', () => {
expect(calculateDiscount(150, undefined)).toBe(15);
});
});
// Code coverage: 100%
// Mutation score: ~95% (kills almost all mutants)
});
// Stryker configuration example (stryker.config.json)
const config = {
mutator: 'javascript',
packageManager: 'npm',
testRunner: 'jest',
reporters: ['html', 'clear-text', 'progress'],
coverageAnalysis: 'perTest',
mutate: [
'src/**/*.js',
'!src/**/*.test.js'
],
thresholds: {
high: 80,
low: 60,
break: 50
}
};
// Example mutation operators
const mutationOperators = {
// Arithmetic operators
ArithmeticOperator: {
'+': ['-', '*', '/'],
'-': ['+', '*', '/'],
'*': ['+', '-', '/'],
'/': ['+', '-', '*']
},
// Relational operators
RelationalOperator: {
'>': ['>=', '<', '<='],
'>=': ['>', '<', '<='],
'<': ['<=', '>', '>='],
'<=': ['<', '>', '>=']
},
// Equality operators
EqualityOperator: {
'==': ['!='],
'!=': ['=='],
'===': ['!=='],
'!==': ['===']
},
// Logical operators
LogicalOperator: {
'&&': ['||'],
'||': ['&&']
},
// Unary operators
UnaryOperator: {
'+': ['-'],
'-': ['+'],
'!': ['']
},
// Block statements
BlockStatement: {
// Remove entire block
'{ /* code */ }': ['']
},
// Conditional expressions
ConditionalExpression: {
'condition ? a : b': ['true', 'false']
}
};
// Analyzing mutation testing results
describe('Mutation Testing Analysis', () => {
/*
Mutation Testing Report:
Total Mutants: 45
Killed: 38 (84%)
Survived: 5 (11%)
Timeout: 1 (2%)
No Coverage: 1 (2%)
Survived Mutants:
1. Line 42: Changed > to >= (boundary condition not tested)
2. Line 58: Removed return statement (missing assertion)
3. Line 73: Changed 0.2 to 0.3 (magic number not validated)
4. Line 91: Changed && to || (logical operator not covered)
5. Line 105: Removed error throw (error case not tested)
Action Items:
- Add test for boundary condition at line 42
- Add assertion to verify return value at line 58
- Use named constants and test specific values at line 73
- Add test case covering both conditions false at line 91
- Add test expecting error throw at line 105
*/
});
// Property-based testing combined with mutation testing
const fc = require('fast-check');
describe('Robust against mutations with property-based tests', () => {
test('discount is always between 0 and original price', () => {
fc.assert(
fc.property(
fc.integer({ min: 0, max: 1000 }),
fc.constantFrom('premium', 'regular', 'guest'),
(price, customerType) => {
const discount = calculateDiscount(price, customerType);
expect(discount).toBeGreaterThanOrEqual(0);
expect(discount).toBeLessThanOrEqual(price);
}
)
);
// This property test kills many mutants automatically
});
});
Mermaid Diagram:
flowchart TD
A[Mutation Testing Process] --> B[Run Original Tests]
B --> C{All Pass?}
C -->|No| D[Fix Tests/Code First]
C -->|Yes| E[Generate Mutants]
E --> F[Mutant 1: Change Operator]
E --> G[Mutant 2: Modify Condition]
E --> H[Mutant 3: Change Constant]
E --> I[Mutant N: ...]
F --> J[Run All Tests]
G --> K[Run All Tests]
H --> L[Run All Tests]
I --> M[Run All Tests]
J --> N{Any Test Fails?}
K --> O{Any Test Fails?}
L --> P{Any Test Fails?}
M --> Q{Any Test Fails?}
N -->|Yes| R[Mutant Killed âś“]
N -->|No| S[Mutant Survived âś—]
O -->|Yes| R
O -->|No| S
P -->|Yes| R
P -->|No| S
Q -->|Yes| R
Q -->|No| S
R --> T[Calculate Mutation Score]
S --> U[Identify Weak Tests]
U --> V[Add/Improve Tests]
T --> W{Score >= Threshold?}
W -->|Yes| X[High Test Quality]
W -->|No| V
V --> A
style S fill:#ffe1e1
style R fill:#e1ffe1
style X fill:#e1f5ff
References:
- Stryker Mutator
- Mutation Testing: A Comprehensive Guide
- Mutation Testing Introduction - PIT
- Real-World Mutation Testing at Google