Episode 3 — NodeJS MongoDB Backend Architecture / 3.18 — Testing Tools
Interview Questions: Testing Tools
Prepare for technical interviews with these testing-focused questions, complete with model answers at beginner, intermediate, and advanced levels, plus a quick-fire reference table.
How to Use This Material
- Cover the model answer before reading it. Speak your answer out loud as if you are in a real interview.
- Time yourself. Beginner: 2-3 minutes per answer. Intermediate: 3-5 minutes. Advanced: 5-10 minutes.
- Structure your answers. Start with a clear definition, explain why it matters, give a concrete code example, and mention trade-offs or edge cases.
- Practice the quick-fire table at the end until you can answer each question in under 15 seconds. Interviewers love candidates who can answer the fundamentals without hesitation.
- Record yourself answering 2-3 questions. Listen back for filler words, vagueness, and missed details. Then try again.
Beginner (Q1-Q4)
Q1. What is the testing pyramid and how should you use it to structure a test suite?
Model Answer:
The testing pyramid is a widely adopted framework for deciding how many of each type of test to write. It has three layers:
Unit tests form the base of the pyramid. They make up roughly 65-80% of your total tests. Each unit test verifies a single function or module in isolation -- all external dependencies like databases, APIs, and file systems are replaced with mocks. Unit tests run in milliseconds, are cheap to write and maintain, and give you the fastest possible feedback when something breaks.
Example: testing a calculateDiscount(price, percent) function with inputs like (100, 20) expecting 80, (0, 50) expecting 0, or (-10, 20) expecting a thrown error.
Integration tests occupy the middle layer, roughly 15-25% of your tests. They verify that multiple components work together correctly. In a Node.js backend, this typically means using Supertest to send an HTTP request through Express, which hits a real (in-memory) database via Mongoose, and you verify both the HTTP response and the actual database state.
Example: POST /api/users with { name: 'Alice', email: 'alice@test.com' } → verify you get a 201 response AND the user actually exists in the database.
E2E (end-to-end) tests sit at the top, just 5-10% of your suite. They automate a real browser to simulate user behavior across the entire application stack: frontend, backend, database, and external services. They provide the highest confidence that the system works as a user expects, but they are the slowest, most expensive, and most fragile tests.
Example: a Playwright test that opens /register, fills out the form, clicks submit, and verifies the dashboard loads with a welcome message.
Why the pyramid shape matters: As you move up the pyramid, tests become slower, more expensive, and more brittle. If you invert the pyramid (mostly E2E, few unit tests), you get a slow CI pipeline (20+ minutes), frequent flaky failures, and difficulty pinpointing which code caused a failure. The pyramid guides you to catch most bugs with fast, cheap unit tests, catch wiring issues with integration tests, and reserve expensive E2E tests for the most critical user flows.
Q2. What is the difference between a unit test, an integration test, and an E2E test?
Model Answer:
These three types differ in scope, speed, what they verify, and how much of the system they exercise.
Unit tests test a single function or class method in complete isolation. Every external dependency is replaced with a mock. The function receives inputs and you verify the outputs. No database, no HTTP calls, no file system.
// Unit test: testing a pure function in isolation
test('calculates total with tax', () => {
expect(calculateTotal(100, 0.08)).toBe(108);
});
Unit tests answer: "Does this one piece of logic produce the correct result?" They run in milliseconds and you should have hundreds of them.
Integration tests test how multiple real components work together. In a Node.js backend, this means sending an actual HTTP request through Express routing, middleware, validation, and database interaction. You verify both the HTTP response and the side effects (data in the database).
// Integration test: real HTTP request through real Express app to real (in-memory) database
test('POST /api/users creates a user', async () => {
const res = await request(app)
.post('/api/users')
.send({ name: 'Alice', email: 'alice@test.com', password: 'Pass123' });
expect(res.status).toBe(201);
expect(res.body.data.name).toBe('Alice');
const userInDb = await User.findOne({ email: 'alice@test.com' });
expect(userInDb).not.toBeNull();
});
Integration tests answer: "Do these components work correctly when wired together?" They run in seconds and you should have dozens.
E2E tests automate a real browser to simulate what an actual user does -- typing into fields, clicking buttons, navigating between pages. They test the entire stack simultaneously.
// E2E test: real browser, real frontend, real backend, real database
test('user registers and sees dashboard', async ({ page }) => {
await page.goto('/register');
await page.fill('[data-testid="email"]', 'alice@test.com');
await page.fill('[data-testid="password"]', 'SecurePass123');
await page.click('[data-testid="submit"]');
await expect(page).toHaveURL(/dashboard/);
await expect(page.locator('text=Welcome, Alice')).toBeVisible();
});
E2E tests answer: "Can a real person complete this workflow in our application?" They run in seconds to minutes and you should have a handful for critical flows only.
Key trade-off: Moving up the pyramid gives you more realistic testing but at the cost of speed, stability, and maintenance. A well-balanced suite uses all three, weighted heavily toward the base.
Q3. What is Test-Driven Development (TDD)?
Model Answer:
TDD is a software development methodology where you write the test before you write the production code. It follows a strict repeating cycle called Red-Green-Refactor:
RED: Write a test that describes one specific behavior you want. Run it. It fails because the production code does not exist yet. This failing test is your specification -- it tells you exactly what to build next.
GREEN: Write the absolute minimum code needed to make the test pass. Do not optimize, do not handle edge cases you have not written tests for, do not refactor. Just make the red test turn green.
REFACTOR: Now that the test passes, clean up the code. Rename variables, extract helper functions, remove duplication, improve readability. Run the tests after each change to ensure they still pass.
Then you repeat the cycle for the next behavior.
Concrete example -- building isValidPassword(password):
Cycle 1:
RED: expect(isValidPassword('Abc12345')).toBe(true)
→ ReferenceError: isValidPassword is not defined
GREEN: function isValidPassword(pw) { return true; }
→ Test passes (crudely, but it passes)
REFACTOR: No changes needed yet.
Cycle 2:
RED: expect(isValidPassword('')).toBe(false)
→ FAIL: expected false, got true
GREEN: function isValidPassword(pw) { return pw.length >= 8; }
→ Both tests pass
REFACTOR: Add a named constant: const MIN_LENGTH = 8;
Cycle 3:
RED: expect(isValidPassword('abcdefgh')).toBe(false)
→ FAIL: no uppercase requirement enforced yet
GREEN: Add /[A-Z]/ check.
→ All three tests pass
Continue for each requirement (digit required, special char, etc.)
Benefits of TDD:
- Forces you to design the API before the implementation
- Every line of production code is covered by a test from the start
- Produces small, focused, testable functions
- Tests serve as living documentation of intended behavior
- Catches design problems early (if something is hard to test, its API is probably wrong)
Drawbacks:
- Slower initial development speed (you write more code up front)
- Can feel counterproductive for exploratory or prototype code where the design is unclear
- Requires discipline -- it is easy to skip the refactor step or write production code without a failing test
When to use TDD: For critical business logic (payments, authentication, data transformations). When NOT to use it: For throwaway prototypes, UI experimentation, or when requirements are changing so rapidly that tests would be rewritten immediately.
Q4. Why do we mock dependencies in unit tests?
Model Answer:
Mocking means replacing a real dependency with a controlled fake implementation during testing. We mock dependencies for four important reasons:
1. Isolation. A unit test should verify only the code under test, not its dependencies. If createOrder() calls emailService.sendConfirmation(), and the email service has a bug, the createOrder test should NOT fail. Mocking the email service isolates the order logic so you know exactly what is being tested.
2. Speed. Real database queries take 5-50ms each. Real HTTP calls to external APIs take 100ms-2s. Mocked calls return in microseconds. When your test suite has 500 tests, the difference between 5 seconds (mocked) and 5 minutes (real) is enormous.
3. Determinism. Tests must produce the same result every time they run, on every machine, regardless of network conditions. A real third-party API might be down, a real database might have different data, network latency might cause timeouts. Mocks return predictable, controlled values.
4. Testing edge cases. Some scenarios are impossible or impractical to reproduce with real dependencies. How do you test "what happens when Stripe returns a 503 error"? You cannot reliably make Stripe return 503 on demand. With a mock, you simply write: stripe.charge.mockRejectedValue(new Error('Service Unavailable')).
Jest provides three mocking tools:
// jest.fn() — standalone mock function (for callbacks, parameters)
const mockCallback = jest.fn().mockReturnValue(42);
myFunction(mockCallback);
expect(mockCallback).toHaveBeenCalledWith('expected-arg');
// jest.mock() — replace an entire imported module
jest.mock('./database');
const db = require('./database');
db.save.mockResolvedValue({ id: '123' });
// jest.spyOn() — wrap an existing method to track calls
const spy = jest.spyOn(console, 'error');
runCode();
expect(spy).toHaveBeenCalledWith('Expected error message');
spy.mockRestore();
The danger of over-mocking: If you mock everything, your tests verify that your mocks behave as expected, not that your real code works. This is why integration tests (with real databases, real middleware, real routing) are essential alongside mocked unit tests. The two approaches complement each other.
Intermediate (Q5-Q8)
Q5. Explain Jest's three mocking strategies and when to use each.
Model Answer:
Jest provides three distinct mocking mechanisms. Choosing the right one depends on what you need to mock and how much control you need.
jest.fn() -- standalone mock function.
Creates a brand-new mock function with built-in call tracking. Use it when you need a mock callback, an injected dependency parameter, or any standalone function.
// Testing a function that takes a callback
const mockCallback = jest.fn()
.mockReturnValueOnce('first')
.mockReturnValueOnce('second')
.mockReturnValue('default');
processItems([1, 2, 3], mockCallback);
expect(mockCallback).toHaveBeenCalledTimes(3);
expect(mockCallback).toHaveBeenNthCalledWith(1, 1);
expect(mockCallback.mock.results[0].value).toBe('first');
jest.mock('./module') -- auto-mock an entire module.
Replaces every exported function in a module with a jest.fn() automatically. Use it when the code under test imports a dependency that you want to control entirely -- database clients, email services, payment APIs, etc.
jest.mock('./database');
const db = require('./database');
const { createUser } = require('./userService');
// Every function in ./database is now a jest.fn()
db.save.mockResolvedValue({ id: '123', name: 'Alice' });
db.findByEmail.mockResolvedValue(null); // No existing user
const user = await createUser({ name: 'Alice', email: 'alice@test.com' });
expect(db.save).toHaveBeenCalledWith('users', expect.objectContaining({ name: 'Alice' }));
You can also provide a custom factory function for precise control:
jest.mock('./emailService', () => ({
sendWelcome: jest.fn().mockResolvedValue({ sent: true, messageId: 'mock-123' }),
sendReset: jest.fn().mockResolvedValue({ sent: true }),
}));
jest.spyOn(object, 'method') -- spy on an existing method.
Wraps a real method to track calls and optionally override its behavior, with the ability to restore the original afterward. Use it when you want to verify a method was called without replacing it, or when you need to temporarily override a method.
// Track calls without changing behavior
const logSpy = jest.spyOn(console, 'log');
performAction();
expect(logSpy).toHaveBeenCalledWith('Action performed');
logSpy.mockRestore(); // Restore original console.log
// Temporarily override a method
const dateSpy = jest.spyOn(Date, 'now').mockReturnValue(1700000000000);
expect(generateTimestamp()).toBe(1700000000000);
dateSpy.mockRestore();
Decision guide:
| Situation | Use |
|---|---|
| Need a mock callback or function parameter | jest.fn() |
| Need to replace an entire imported module | jest.mock('./module') |
| Need custom mock implementation for a module | jest.mock('./module', () => ({...})) |
| Need to track calls to an existing method without replacing it | jest.spyOn(obj, 'method') |
| Need to temporarily override and then restore a method | jest.spyOn(obj, 'method').mockReturnValue(...) then .mockRestore() |
Always call jest.clearAllMocks() in afterEach to prevent mock state from leaking between tests.
Q6. How do you test asynchronous code in Jest?
Model Answer:
Asynchronous code is the norm in Node.js -- database queries, HTTP calls, file reads, and timers are all async. Jest supports multiple patterns for testing it, but the critical rule is: you must either await or return the promise, or Jest will not wait for it to settle, and the test will pass vacuously even if the promise rejects.
Pattern 1: async/await (recommended for all new code)
test('fetches user data successfully', async () => {
const user = await userService.findById('123');
expect(user.name).toBe('Alice');
expect(user.email).toBe('alice@test.com');
});
test('throws when user is not found', async () => {
await expect(userService.findById('nonexistent'))
.rejects.toThrow('User not found');
});
Pattern 2: .resolves / .rejects (concise one-liner style)
test('resolves with user data', () => {
return expect(userService.findById('123'))
.resolves.toMatchObject({ name: 'Alice' });
// NOTE: you MUST return the chain or the test ends before the promise settles
});
test('rejects for invalid ID', () => {
return expect(userService.findById('bad'))
.rejects.toThrow('Not found');
});
Pattern 3: done callback (for legacy callback-based APIs)
test('reads file content via callback', (done) => {
fs.readFile('data.txt', 'utf8', (err, data) => {
try {
expect(err).toBeNull();
expect(data).toContain('expected content');
done();
} catch (error) {
done(error); // Pass the error to done() so Jest reports it correctly
}
});
});
Testing async error cases in detail:
// Method 1: rejects.toThrow (clean, preferred)
test('rejects with specific error', async () => {
await expect(service.create(null)).rejects.toThrow('Invalid input');
});
// Method 2: try/catch (when you need to inspect the error object)
test('rejects with a ValidationError', async () => {
try {
await service.create(null);
fail('Expected an error to be thrown');
} catch (error) {
expect(error).toBeInstanceOf(ValidationError);
expect(error.statusCode).toBe(400);
expect(error.details).toHaveLength(2);
}
});
Testing timers and timeouts:
test('times out after 5 seconds', async () => {
jest.useFakeTimers();
const promise = service.longRunningOperation();
jest.advanceTimersByTime(6000);
await expect(promise).rejects.toThrow('Operation timed out');
jest.useRealTimers();
});
Common pitfall: Forgetting await before expect(...).rejects.toThrow(...). Without await, the test exits immediately (passing), and the rejection becomes an unhandled promise rejection that surfaces as a confusing error in a completely different test.
Q7. How do you use Supertest to test Express API endpoints?
Model Answer:
Supertest is an HTTP assertion library that lets you send real HTTP requests to your Express application and assert on the responses -- all without starting a live server or binding to a port.
The key architectural requirement is separating your Express app from the server listener:
// app.js — EXPORT the Express app. NO app.listen() here.
const express = require('express');
const app = express();
app.use(express.json());
app.get('/api/health', (req, res) => res.json({ status: 'ok' }));
app.post('/api/users', async (req, res) => { /* ... */ });
module.exports = app; // Export for Supertest
// server.js — Import and listen. Only used in production.
const app = require('./app');
app.listen(process.env.PORT || 3000);
If app.js calls app.listen(), every test file that imports it would start a server on the same port, causing EADDRINUSE errors.
Testing different HTTP methods:
const request = require('supertest');
const app = require('../app');
// GET — with query parameters
test('GET /api/users returns paginated users', async () => {
const res = await request(app)
.get('/api/users')
.query({ page: 1, limit: 10 })
.expect(200)
.expect('Content-Type', /json/);
expect(res.body.data).toBeInstanceOf(Array);
expect(res.body.data.length).toBeLessThanOrEqual(10);
});
// POST — with request body
test('POST /api/users creates a user', async () => {
const res = await request(app)
.post('/api/users')
.send({ name: 'Alice', email: 'alice@test.com', password: 'Pass123' })
.expect(201);
expect(res.body.data).toMatchObject({ name: 'Alice', email: 'alice@test.com' });
expect(res.body.data).not.toHaveProperty('password');
});
// Authenticated request
test('GET /api/me returns current user', async () => {
const res = await request(app)
.get('/api/me')
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(res.body.data.email).toBe('alice@test.com');
});
// Error cases
test('returns 401 without auth token', async () => {
await request(app).get('/api/me').expect(401);
});
test('returns 400 for invalid input', async () => {
const res = await request(app)
.post('/api/users')
.send({ name: '' }) // Missing required fields
.expect(400);
expect(res.body).toHaveProperty('errors');
});
For true integration tests, pair Supertest with mongodb-memory-server to test against a real database:
// tests/setup.js — configure in jest.config.js as setupFilesAfterSetup
const { MongoMemoryServer } = require('mongodb-memory-server');
const mongoose = require('mongoose');
let mongoServer;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
await mongoose.connect(mongoServer.getUri());
});
afterEach(async () => {
for (const key in mongoose.connection.collections) {
await mongoose.connection.collections[key].deleteMany({});
}
});
afterAll(async () => {
await mongoose.connection.close();
await mongoServer.stop();
});
This approach tests the full request lifecycle -- Express routing, middleware, validation, database interaction, and response formatting -- without any external infrastructure.
Q8. What are code coverage metrics and what are their limitations?
Model Answer:
Code coverage measures what percentage of your source code is executed when your test suite runs. Jest generates coverage reports with npx jest --coverage and reports four metrics:
| Metric | What It Measures | Example |
|---|---|---|
| Statements | % of executable statements run | const x = 5; is one statement |
| Branches | % of conditional paths taken | An if/else has 2 branches; both must be hit for 100% |
| Functions | % of declared functions that were called | An unused helper function reduces this |
| Lines | % of code lines executed | Similar to statements but counted per line |
Branch coverage deserves special attention because it reveals untested conditional logic -- the if/else error handling paths, ternary fallbacks, and switch cases that often contain the most subtle bugs.
A coverage report might look like:
File | % Stmts | % Branch | % Funcs | % Lines |
authService.js | 92.3 | 60.0 | 100.0 | 92.3 |
This tells you: all functions in authService.js were called (100% functions), but 40% of the conditional branches were never exercised (60% branches). Those untested branches are almost certainly error handling paths -- what happens when the token is expired, when the user does not exist, when the password hash fails.
The limitations -- this is what interviewers are really asking about:
- Coverage measures execution, not correctness. A test that calls a function with no
expect()assertions gets full line coverage while verifying nothing:
test('covers the code', () => {
calculateTotal(100, 0.08); // No assertion — useless, but counts as covered
});
-
100% coverage does not mean zero bugs. The function might have a logic error that passes through every test because the assertions are too weak or test the wrong thing.
-
Coverage cannot detect missing tests. If your function should handle
nullinput but you never wrote a test fornull, coverage will not tell you. It only reports on code that exists, not on code that is missing. -
Chasing coverage numbers leads to bad tests. Teams aiming for 100% often write meaningless tests for trivial code (getters, setters, config files) instead of writing thorough tests for critical business logic.
-
Mocked code inflates coverage. If you mock everything, you achieve high coverage of your mocks, not your real code.
Practical guidance:
- 80% overall line coverage is a strong practical target
- 90%+ for critical business logic (payments, auth, data transformations)
- Focus on branch coverage -- that is where error handling bugs hide
- Use coverage to identify untested areas, not as a scorecard
- Pair coverage with code review: are the assertions meaningful, or are they just
toBeDefined()?
Advanced (Q9-Q11)
Q9. How would you design a testing strategy for a microservices architecture?
Model Answer:
Testing microservices is fundamentally different from testing a monolith because you have multiple independently deployed services that communicate over the network. Each service can change independently, which introduces the risk of breaking inter-service communication. The testing strategy must address both individual service correctness and inter-service compatibility.
Layer 1: Unit tests within each service.
This is identical to monolith testing. Each service has its own unit tests for business logic, utility functions, validators, and service-layer methods with mocked dependencies. Fast, isolated, run on every commit. Tools: Jest, jest.mock().
Layer 2: Integration tests within each service.
Each service tests its own API endpoints against a real (in-memory) database. For dependencies on OTHER services, you mock the inter-service HTTP client:
// In the Order Service test suite
jest.mock('./clients/userServiceClient');
jest.mock('./clients/productServiceClient');
userServiceClient.getUser.mockResolvedValue({ id: 'u1', name: 'Alice' });
productServiceClient.getProduct.mockResolvedValue({ id: 'p1', price: 29.99 });
const res = await request(orderApp)
.post('/api/orders')
.send({ userId: 'u1', productId: 'p1', quantity: 2 });
expect(res.status).toBe(201);
expect(res.body.total).toBe(59.98);
Layer 3: Contract tests (the microservices-specific layer).
This is the critical layer that distinguishes microservice testing from monolith testing. Contract testing (using tools like Pact) verifies that the API contract between a consumer and a provider is maintained.
The consumer (e.g., Order Service) defines a "contract" that says: "When I call GET /api/users/123, I expect a response with { id: string, name: string, email: string }." The provider (User Service) runs this contract against its real endpoint to verify it fulfills the expectation.
If User Service renames name to fullName in a refactor, the contract test fails BEFORE the change is deployed, preventing a production break.
Consumer contract:
"GET /api/users/123 should return { id: '123', name: 'Alice', email: '...' }"
Provider verification:
Run the contract against real User Service endpoint → PASS or FAIL
This eliminates the need to run all services together in a test environment just to check that they can talk to each other.
Layer 4: E2E tests in a staging environment.
Deploy all services to a staging environment that mirrors production. Run a small suite of E2E tests (5-10 critical user flows) that exercise the full system. These are expensive and slow, so keep them focused on the highest-value paths: user registration, checkout, search, and core business operations.
Layer 5: Synthetic monitoring in production.
Run lightweight smoke tests against production on a schedule (every 5 minutes). These verify that each service is healthy and that the critical inter-service flows still work. Tools: Playwright scripts in a cron job, or dedicated synthetic monitoring services.
Additional considerations:
- Asynchronous communication (message queues): If services communicate via RabbitMQ or Kafka, test producers and consumers separately with contract tests on the message schema.
- Service virtualization: When a dependent service is unavailable in the test environment, use WireMock or mock servers to simulate it.
- Database isolation: Each service has its own test database. Never share test databases between services.
- Deployment order independence: Contract tests should run in both the consumer's and provider's CI pipelines. If a provider breaks a consumer's contract, the provider's build fails.
Q10. How would you test WebSocket or real-time connections?
Model Answer:
Testing WebSocket connections is more complex than REST endpoints because WebSockets are stateful, bidirectional, and event-driven. The connection persists, messages flow in both directions, and timing matters.
Unit testing the message handlers:
First, separate the WebSocket message handling logic from the WebSocket server itself, just like separating Express app from server. This lets you unit test the handlers with plain function calls.
// messageHandler.js
function handleMessage(data, broadcast) {
const parsed = JSON.parse(data);
if (parsed.type === 'chat') {
broadcast(JSON.stringify({ type: 'chat', user: parsed.user, text: parsed.text, timestamp: Date.now() }));
}
}
// messageHandler.test.js
test('broadcasts chat messages with timestamp', () => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2025-06-15T12:00:00Z'));
const mockBroadcast = jest.fn();
handleMessage(JSON.stringify({ type: 'chat', user: 'Alice', text: 'Hello' }), mockBroadcast);
expect(mockBroadcast).toHaveBeenCalledWith(
expect.stringContaining('"user":"Alice"')
);
jest.useRealTimers();
});
Integration testing the WebSocket server:
const { Server } = require('ws');
const WebSocket = require('ws');
describe('WebSocket Chat Server', () => {
let wss;
let port;
beforeAll((done) => {
wss = new Server({ port: 0 }); // Random port avoids conflicts
wss.on('listening', () => {
port = wss.address().port;
done();
});
wss.on('connection', (ws) => {
ws.on('message', (data) => {
wss.clients.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.send(data.toString());
}
});
});
});
});
afterAll(() => wss.close());
test('broadcasts messages to all connected clients', (done) => {
const sender = new WebSocket(`ws://localhost:${port}`);
const receiver = new WebSocket(`ws://localhost:${port}`);
let connected = 0;
const onOpen = () => {
connected++;
if (connected === 2) {
sender.send(JSON.stringify({ user: 'Alice', text: 'Hello' }));
}
};
sender.on('open', onOpen);
receiver.on('open', onOpen);
receiver.on('message', (data) => {
const msg = JSON.parse(data);
expect(msg.user).toBe('Alice');
expect(msg.text).toBe('Hello');
sender.close();
receiver.close();
done();
});
});
test('handles client disconnection gracefully', (done) => {
const client = new WebSocket(`ws://localhost:${port}`);
client.on('open', () => client.close());
client.on('close', (code) => {
expect(code).toBe(1000); // Normal closure
done();
});
});
});
E2E testing WebSockets with Playwright:
Playwright supports multi-context testing, which makes it ideal for testing real-time features between two users:
test('chat messages appear in real time', async ({ browser }) => {
const aliceCtx = await browser.newContext();
const bobCtx = await browser.newContext();
const alicePage = await aliceCtx.newPage();
const bobPage = await bobCtx.newPage();
// Both users navigate to chat
await alicePage.goto('/chat');
await bobPage.goto('/chat');
// Alice sends a message
await alicePage.fill('[data-testid="message-input"]', 'Hello Bob!');
await alicePage.click('[data-testid="send-button"]');
// Bob sees it immediately (via WebSocket, no page refresh)
await expect(bobPage.locator('[data-testid="messages"]'))
.toContainText('Hello Bob!');
await aliceCtx.close();
await bobCtx.close();
});
Testing authentication for WebSockets:
test('rejects unauthenticated WebSocket connections', (done) => {
const ws = new WebSocket(`ws://localhost:${port}`);
ws.on('error', () => {}); // Prevent unhandled error
ws.on('close', (code) => {
expect(code).toBe(4001); // Custom close code for "Unauthorized"
done();
});
});
test('accepts authenticated WebSocket connections', (done) => {
const ws = new WebSocket(`ws://localhost:${port}`, {
headers: { Authorization: `Bearer ${validToken}` }
});
ws.on('open', () => {
expect(ws.readyState).toBe(WebSocket.OPEN);
ws.close();
done();
});
});
Key challenges:
- Timing: Use
done()callback or wrap WebSocket events in Promises with timeouts. - Multiple clients: Create multiple client connections to test broadcasting and presence.
- Reconnection: Close the server, wait, restart it, verify clients reconnect automatically.
- Load testing: Use Artillery with WebSocket support (
engine: ws) to test concurrent connections.
Q11. How would you design a CI/CD test pipeline with parallel tests, test splitting, and flaky test handling?
Model Answer:
A production-grade CI/CD testing pipeline must balance three competing goals: fast feedback (developers do not wait more than 10-15 minutes), thoroughness (catch all real bugs), and reliability (no false failures from flaky tests). Here is the architecture I would build:
Pipeline structure (staged, fail-fast):
Stage 1: Lint + Type Check ~30 seconds
└── ESLint, Prettier check, TypeScript compilation
→ FAIL FAST: if code has syntax errors or lint violations, stop here
Stage 2: Unit Tests + Coverage ~1-2 minutes
└── Jest with mocks, coverage report, threshold enforcement
→ FAIL FAST: if a pure logic test fails, do not waste time on slow tests
Stage 3: Integration Tests ~3-5 minutes
└── Jest + Supertest + mongodb-memory-server (or service container)
→ Only runs if Stage 2 passes (needs: unit-tests)
Stage 4: E2E Tests (sharded) ~5-8 minutes total
├── Shard 1: Authentication flows
├── Shard 2: Core CRUD operations
├── Shard 3: Search and filtering
└── Shard 4: Checkout and payments
→ Only runs if Stage 3 passes; 4 parallel machines, ~2 min each
Total pipeline time: approximately 10-12 minutes, with unit test results available in under 2 minutes.
Parallel execution:
For Jest, parallelism is built in -- test files run in separate worker processes by default. For E2E tests with Playwright, I use sharding to distribute test files across CI machines:
jobs:
e2e:
strategy:
fail-fast: false # Let all shards complete even if one fails
matrix:
shard: [1, 2, 3, 4]
steps:
- run: npx playwright test --shard=${{ matrix.shard }}/4
Setting fail-fast: false is critical -- if shard 1 fails, you still want shards 2-4 to finish so you see ALL failures, not just the first one.
Test splitting strategy:
Static sharding (dividing test files evenly by count) can be unbalanced if some files are much slower than others. For optimal splitting:
- Record the execution time of each test file over recent runs.
- Distribute files across shards to equalize total time per shard.
- Playwright's built-in
--shardflag handles this with reasonable balance. For Jest, tools likejest-shardor CI-native test splitting (CircleCI, GitHub Actions matrix) can optimize further.
Flaky test handling -- the most important part:
Flaky tests (non-deterministic tests that pass and fail without code changes) are the biggest threat to CI trust. If developers start ignoring CI failures because "it's probably just a flaky test," you have lost the entire value of automated testing.
My approach has five layers:
1. Prevention (most important). Enforce standards in code review:
- Every test must use
data-testidselectors (not CSS classes) - No
sleep()orsetTimeout()-- use condition-based waits (waitForResponse,waitForSelector) - Every test must set up its own state (no dependency on test execution order)
- All external services must be mocked in unit tests
afterEachmust clean up database state and mock state
2. Detection. Track test results over time. A test that fails more than once in 20 runs without corresponding code changes is flagged as flaky. Playwright's built-in reporter tracks this. For Jest, use a custom reporter or CI analytics.
3. Automatic retries (with limits). Allow 1-2 retries for E2E tests in CI. This handles genuine transient issues (browser rendering timing, CI machine load) without masking real bugs.
// playwright.config.js
retries: process.env.CI ? 2 : 0
A test that needs all 2 retries to pass is flagged for investigation.
4. Quarantine. If a flaky test cannot be fixed immediately, move it to a quarantine suite that runs but does not block merges. Create a high-priority ticket to fix it. Set a policy: quarantined tests must be fixed within one sprint or deleted.
5. Root cause analysis. The most common causes and fixes:
| Cause | How to Identify | Fix |
|---|---|---|
| Race condition with API | Fails more on slow CI machines | Add await page.waitForResponse('**/api/...') before asserting |
| Shared database state | Fails when run after a specific test | Reset DB in beforeEach/afterEach |
| Date/time dependency | Fails at midnight or month boundaries | Use jest.useFakeTimers() with fixed dates |
| CSS animation timing | Element "not visible" errors | Disable animations: * { transition: none !important; } |
| Network timeout | Fails intermittently on slow CI | Increase timeouts, mock external services |
| Test order dependency | Passes alone, fails in full suite | Tests must be independent; use --randomize flag |
GitHub Actions implementation:
name: CI Pipeline
on: [push, pull_request]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run lint
unit:
needs: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npx jest --coverage --testPathPattern=unit
- uses: actions/upload-artifact@v4
with: { name: coverage, path: coverage/ }
integration:
needs: unit
runs-on: ubuntu-latest
services:
mongo: { image: 'mongo:7', ports: ['27017:27017'] }
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npx jest --testPathPattern=integration
env: { MONGODB_URI: 'mongodb://localhost:27017/test' }
e2e:
needs: integration
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix: { shard: [1, 2, 3, 4] }
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npx playwright install --with-deps
- run: npx playwright test --shard=${{ matrix.shard }}/4
- uses: actions/upload-artifact@v4
if: always()
with:
name: e2e-report-${{ matrix.shard }}
path: playwright-report/
Metrics I would monitor on a dashboard:
- Pipeline total time -- target under 15 minutes
- Flaky test rate -- target under 1% (flaky runs / total runs)
- Coverage trend -- should be stable or increasing over time
- Time to first feedback -- unit test results in under 2 minutes
- E2E test count -- should grow slowly (10-30 total), not exponentially
Quick-Fire Table
| # | Question | One-Line Answer |
|---|---|---|
| 1 | What does expect(a).toEqual(b) check? | Deep value equality -- recursively compares all properties and nested values |
| 2 | How do you skip a test in Jest? | it.skip('name', () => {...}) or xit('name', () => {...}) |
| 3 | What does jest.clearAllMocks() do? | Resets call history and arguments on all mocks but keeps their return value implementations |
| 4 | What is beforeEach used for? | Runs a setup function before every individual test in the current describe block |
| 5 | How does Supertest test Express without a running server? | It creates a temporary internal server on a random ephemeral port, sends the request, and tears it down |