14 KiB
14 KiB
title, impact, impactDescription, tags
| title | impact | impactDescription | tags |
|---|---|---|---|
| MSW (Mock Service Worker) Setup for Testing | HIGH | Proper API mocking without ORM complexity | testing, msw, api-mocking, mock-service-worker |
MSW (Mock Service Worker) Setup for Testing
Use MSW (Mock Service Worker) for API mocking in tests. MSW provides a cleaner approach than Mirage by intercepting requests at the network level without introducing unnecessary ORM patterns or abstractions.
Incorrect (using Mirage with ORM complexity):
// mirage/config.js
export default function () {
this.namespace = '/api';
// Complex schema and factories
this.get('/users', (schema) => {
return schema.users.all();
});
// Need to maintain schema, factories, serializers
this.post('/users', (schema, request) => {
let attrs = JSON.parse(request.requestBody);
return schema.users.create(attrs);
});
}
Correct (using MSW with simple network mocking):
// tests/helpers/msw.js
import { http, HttpResponse } from 'msw';
// Simple request/response mocking
export const handlers = [
http.get('/api/users', () => {
return HttpResponse.json([
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
]);
}),
http.post('/api/users', async ({ request }) => {
const user = await request.json();
return HttpResponse.json({ id: 3, ...user }, { status: 201 });
}),
];
Why MSW over Mirage:
- Better conventions - Mock at the network level, not with an ORM
- Simpler mental model - Define request handlers, return responses
- Doesn't lead developers astray - No schema migrations or factories to maintain
- Works everywhere - Same mocks work in tests, Storybook, and development
- More realistic - Actually intercepts fetch/XMLHttpRequest
Reference: Ember.js Community Discussion on MSW
Installation
npm install --save-dev msw
Setup Test Helper
Create a test helper to set up MSW in your tests:
// tests/helpers/msw.js
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
// Define default handlers that apply to all tests
const defaultHandlers = [
// Add default handlers here if needed
];
export function setupMSW(hooks, handlers = []) {
const server = setupServer(...defaultHandlers, ...handlers);
hooks.beforeEach(function () {
server.listen({ onUnhandledRequest: 'warn' });
});
hooks.afterEach(function () {
server.resetHandlers();
});
hooks.after(function () {
server.close();
});
return { server };
}
// Re-export for convenience
export { http, HttpResponse };
Basic Usage in Tests
// tests/acceptance/users-test.js
import { module, test } from 'qunit';
import { visit, currentURL, click } from '@ember/test-helpers';
import { setupApplicationTest } from 'ember-qunit';
import { setupMSW, http, HttpResponse } from 'my-app/tests/helpers/msw';
module('Acceptance | users', function (hooks) {
setupApplicationTest(hooks);
const { server } = setupMSW(hooks);
test('displays list of users', async function (assert) {
server.use(
http.get('/api/users', () => {
return HttpResponse.json({
data: [
{
id: '1',
type: 'user',
attributes: { name: 'Alice', email: 'alice@example.com' },
},
{
id: '2',
type: 'user',
attributes: { name: 'Bob', email: 'bob@example.com' },
},
],
});
}),
);
await visit('/users');
assert.strictEqual(currentURL(), '/users');
assert.dom('[data-test-user-item]').exists({ count: 2 });
assert.dom('[data-test-user-name]').hasText('Alice');
});
test('handles server errors gracefully', async function (assert) {
server.use(
http.get('/api/users', () => {
return HttpResponse.json({ errors: [{ title: 'Server Error' }] }, { status: 500 });
}),
);
await visit('/users');
assert.dom('[data-test-error-message]').exists();
assert.dom('[data-test-error-message]').containsText('Server Error');
});
});
Mocking POST/PUT/DELETE Requests
import { visit, click, fillIn } from '@ember/test-helpers';
test('creates a new user', async function (assert) {
let capturedRequest = null;
server.use(
http.post('/api/users', async ({ request }) => {
capturedRequest = await request.json();
return HttpResponse.json(
{
data: {
id: '3',
type: 'user',
attributes: capturedRequest.data.attributes,
},
},
{ status: 201 },
);
}),
);
await visit('/users/new');
await fillIn('[data-test-name-input]', 'Charlie');
await fillIn('[data-test-email-input]', 'charlie@example.com');
await click('[data-test-submit-button]');
assert.strictEqual(currentURL(), '/users/3');
assert.deepEqual(capturedRequest.data.attributes, {
name: 'Charlie',
email: 'charlie@example.com',
});
});
test('updates an existing user', async function (assert) {
server.use(
http.get('/api/users/1', () => {
return HttpResponse.json({
data: {
id: '1',
type: 'user',
attributes: { name: 'Alice', email: 'alice@example.com' },
},
});
}),
http.patch('/api/users/1', async ({ request }) => {
const body = await request.json();
return HttpResponse.json({
data: {
id: '1',
type: 'user',
attributes: body.data.attributes,
},
});
}),
);
await visit('/users/1/edit');
await fillIn('[data-test-name-input]', 'Alice Updated');
await click('[data-test-submit-button]');
assert.dom('[data-test-user-name]').hasText('Alice Updated');
});
test('deletes a user', async function (assert) {
server.use(
http.get('/api/users', () => {
return HttpResponse.json({
data: [{ id: '1', type: 'user', attributes: { name: 'Alice' } }],
});
}),
http.delete('/api/users/1', () => {
return new HttpResponse(null, { status: 204 });
}),
);
await visit('/users');
await click('[data-test-delete-button]');
assert.dom('[data-test-user-item]').doesNotExist();
});
Query Parameters and Dynamic Routes
test('filters users by query parameter', async function (assert) {
server.use(
http.get('/api/users', ({ request }) => {
const url = new URL(request.url);
const searchQuery = url.searchParams.get('filter[name]');
const users = [
{ id: '1', type: 'user', attributes: { name: 'Alice' } },
{ id: '2', type: 'user', attributes: { name: 'Bob' } },
];
const filtered = searchQuery
? users.filter((u) => u.attributes.name.includes(searchQuery))
: users;
return HttpResponse.json({ data: filtered });
}),
);
await visit('/users?filter[name]=Alice');
assert.dom('[data-test-user-item]').exists({ count: 1 });
assert.dom('[data-test-user-name]').hasText('Alice');
});
test('handles dynamic route segments', async function (assert) {
server.use(
http.get('/api/users/:id', ({ params }) => {
return HttpResponse.json({
data: {
id: params.id,
type: 'user',
attributes: { name: `User ${params.id}` },
},
});
}),
);
await visit('/users/42');
assert.dom('[data-test-user-name]').hasText('User 42');
});
Network Delays and Race Conditions
test('handles slow network responses', async function (assert) {
server.use(
http.get('/api/users', async () => {
// Simulate network delay
await new Promise((resolve) => setTimeout(resolve, 100));
return HttpResponse.json({
data: [{ id: '1', type: 'user', attributes: { name: 'Alice' } }],
});
}),
);
const visitPromise = visit('/users');
// Loading state should be visible
assert.dom('[data-test-loading-spinner]').exists();
await visitPromise;
assert.dom('[data-test-loading-spinner]').doesNotExist();
assert.dom('[data-test-user-item]').exists();
});
Shared Handlers with Reusable Fixtures
// tests/helpers/msw-handlers.js
import { http, HttpResponse } from 'msw';
export const userHandlers = {
list: (users = []) => {
return http.get('/api/users', () => {
return HttpResponse.json({ data: users });
});
},
get: (user) => {
return http.get(`/api/users/${user.id}`, () => {
return HttpResponse.json({ data: user });
});
},
create: (attributes) => {
return http.post('/api/users', () => {
return HttpResponse.json(
{
data: {
id: String(Math.random()),
type: 'user',
attributes,
},
},
{ status: 201 },
);
});
},
};
// Common fixtures
export const fixtures = {
users: {
alice: {
id: '1',
type: 'user',
attributes: { name: 'Alice', email: 'alice@example.com' },
},
bob: {
id: '2',
type: 'user',
attributes: { name: 'Bob', email: 'bob@example.com' },
},
},
};
// tests/acceptance/users-test.js
import { userHandlers, fixtures } from 'my-app/tests/helpers/msw-handlers';
test('displays list of users', async function (assert) {
server.use(userHandlers.list([fixtures.users.alice, fixtures.users.bob]));
await visit('/users');
assert.dom('[data-test-user-item]').exists({ count: 2 });
});
Integration Test Setup
MSW works in integration tests too:
// tests/integration/components/user-list-test.js
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, waitFor } from '@ember/test-helpers';
import { setupMSW, http, HttpResponse } from 'my-app/tests/helpers/msw';
import UserList from 'my-app/components/user-list';
module('Integration | Component | user-list', function (hooks) {
setupRenderingTest(hooks);
const { server } = setupMSW(hooks);
test('fetches and displays users', async function (assert) {
server.use(
http.get('/api/users', () => {
return HttpResponse.json({
data: [{ id: '1', type: 'user', attributes: { name: 'Alice' } }],
});
}),
);
await render(
<template>
<UserList />
</template>,
);
// Wait for async data to load
await waitFor('[data-test-user-item]');
assert.dom('[data-test-user-item]').exists();
assert.dom('[data-test-user-name]').hasText('Alice');
});
});
Best Practices
- Define handlers per test - Use
server.use()in individual tests rather than global handlers - Reset between tests - The helper automatically resets handlers after each test
- Use JSON:API format - Keep responses consistent with your API format
- Test error states - Mock various HTTP error codes (400, 401, 403, 404, 500)
- Capture requests - Use the request object to verify what your app sent
- Use fixtures - Create reusable test data to keep tests DRY
- Simulate delays - Test loading states with artificial delays
- Type-safe responses - In TypeScript, type your response payloads
Common Patterns
Default handlers for all tests:
// tests/helpers/msw.js
const defaultHandlers = [
// Always return current user
http.get('/api/current-user', () => {
return HttpResponse.json({
data: {
id: '1',
type: 'user',
attributes: { name: 'Test User', role: 'admin' },
},
});
}),
];
One-time handlers (don't persist):
// MSW handlers persist until resetHandlers() is called
// The test helper automatically resets after each test
// For a one-time handler within a test, manually reset:
test('one-time response', async function (assert) {
server.use(
http.get('/api/special', () => {
return HttpResponse.json({ data: 'special' });
}),
);
// First request gets mocked response
await visit('/special');
assert.dom('[data-test-data]').hasText('special');
// Reset to remove this handler
server.resetHandlers();
// Subsequent requests will use default handlers or be unhandled
});
Conditional responses:
http.post('/api/login', async ({ request }) => {
const { email, password } = await request.json();
if (email === 'test@example.com' && password === 'password') {
return HttpResponse.json({
data: { token: 'abc123' },
});
}
return HttpResponse.json({ errors: [{ title: 'Invalid credentials' }] }, { status: 401 });
});
Migration from Mirage
If migrating from Mirage:
- Remove
ember-cli-miragedependency - Delete
mirage/directory (models, factories, scenarios) - Install MSW:
npm install --save-dev msw - Create the MSW test helper (see above)
- Replace
setupMirage(hooks)withsetupMSW(hooks) - Convert Mirage handlers:
this.server.get()→http.get()this.server.create()→ Return inline JSONthis.server.createList()→ Return array of JSON objects
Before (Mirage):
test('lists posts', async function (assert) {
this.server.createList('post', 3);
await visit('/posts');
assert.dom('[data-test-post]').exists({ count: 3 });
});
After (MSW):
test('lists posts', async function (assert) {
server.use(
http.get('/api/posts', () => {
return HttpResponse.json({
data: [
{ id: '1', type: 'post', attributes: { title: 'Post 1' } },
{ id: '2', type: 'post', attributes: { title: 'Post 2' } },
{ id: '3', type: 'post', attributes: { title: 'Post 3' } },
],
});
}),
);
await visit('/posts');
assert.dom('[data-test-post]').exists({ count: 3 });
});
Reference: MSW Documentation