542 lines
14 KiB
Markdown
542 lines
14 KiB
Markdown
---
|
|
title: MSW (Mock Service Worker) Setup for Testing
|
|
impact: HIGH
|
|
impactDescription: Proper API mocking without ORM complexity
|
|
tags: 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):**
|
|
|
|
```javascript
|
|
// 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):**
|
|
|
|
```javascript
|
|
// 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](https://discuss.emberjs.com/t/my-cookbook-for-various-emberjs-things/19679)
|
|
|
|
### Installation
|
|
|
|
```bash
|
|
npm install --save-dev msw
|
|
```
|
|
|
|
### Setup Test Helper
|
|
|
|
Create a test helper to set up MSW in your tests:
|
|
|
|
```javascript
|
|
// 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
|
|
|
|
```javascript
|
|
// 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
|
|
|
|
```javascript
|
|
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
|
|
|
|
```javascript
|
|
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
|
|
|
|
```javascript
|
|
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
|
|
|
|
```javascript
|
|
// 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' },
|
|
},
|
|
},
|
|
};
|
|
```
|
|
|
|
```javascript
|
|
// 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:
|
|
|
|
```javascript
|
|
// 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
|
|
|
|
1. **Define handlers per test** - Use `server.use()` in individual tests rather than global handlers
|
|
2. **Reset between tests** - The helper automatically resets handlers after each test
|
|
3. **Use JSON:API format** - Keep responses consistent with your API format
|
|
4. **Test error states** - Mock various HTTP error codes (400, 401, 403, 404, 500)
|
|
5. **Capture requests** - Use the request object to verify what your app sent
|
|
6. **Use fixtures** - Create reusable test data to keep tests DRY
|
|
7. **Simulate delays** - Test loading states with artificial delays
|
|
8. **Type-safe responses** - In TypeScript, type your response payloads
|
|
|
|
### Common Patterns
|
|
|
|
**Default handlers for all tests:**
|
|
|
|
```javascript
|
|
// 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):**
|
|
|
|
```javascript
|
|
// 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:**
|
|
|
|
```javascript
|
|
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:
|
|
|
|
1. Remove `ember-cli-mirage` dependency
|
|
2. Delete `mirage/` directory (models, factories, scenarios)
|
|
3. Install MSW: `npm install --save-dev msw`
|
|
4. Create the MSW test helper (see above)
|
|
5. Replace `setupMirage(hooks)` with `setupMSW(hooks)`
|
|
6. Convert Mirage handlers:
|
|
- `this.server.get()` → `http.get()`
|
|
- `this.server.create()` → Return inline JSON
|
|
- `this.server.createList()` → Return array of JSON objects
|
|
|
|
**Before (Mirage):**
|
|
|
|
```javascript
|
|
test('lists posts', async function (assert) {
|
|
this.server.createList('post', 3);
|
|
await visit('/posts');
|
|
assert.dom('[data-test-post]').exists({ count: 3 });
|
|
});
|
|
```
|
|
|
|
**After (MSW):**
|
|
|
|
```javascript
|
|
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](https://mswjs.io/docs/)
|