0.2.0 • Published 9 months ago

msw-scenarios v0.2.0

Weekly downloads
-
License
MIT
Repository
github
Last release
9 months ago

msw-scenarios

msw-scenarios is a type-safe preset management system built on top of MSW (Mock Service Worker) 2.x.x. This library enhances MSW with a powerful preset system while maintaining complete TypeScript integration, ensuring that your API mocks are both flexible and type-safe during development and testing.

This library was inspired by the presentation at WOOWACON 2023:
프론트엔드 모킹 환경에 감칠맛 더하기

✨ Key Features

  • 🔒 Enhanced Type Safety: Built from the ground up with TypeScript
  • 🔄 MSW Compatibility: Works seamlessly with MSW 2.x.x
  • 👥 Profile Management: Create and switch between mock scenarios
  • 🎮 UI Integration: Build custom UI tools with state management API
  • 🛠 Developer Friendly: Simple, intuitive API design

📦 Installation

npm install msw-scenarios msw
# or
pnpm add msw-scenarios msw
# or
yarn add msw-scenarios msw

📚 Usage

Basic Handler Setup

Setting Up msw-scenarios

import { extendHandlers } from 'msw-scenarios';
import { setupWorker } from 'msw';

const handlers = extendHandlers(userHandler);
const worker = setupWorker(...handlers.handlers);

// Register with workerManager
workerManager.setupWorker(worker);

// Start the worker
worker.start();

For Node.js

import { setupServer } from 'msw/node';
import { workerManager } from 'msw-scenarios';
import { handlers } from './mocks/handlers';

// Create MSW server
const server = setupServer();

// Register with workerManager
workerManager.setupServer(server);

// Start the server in your test setup
beforeAll(() => {
  server.listen({ onUnhandledRequest: 'error' });
});

afterEach(() => {
  workerManager.resetHandlers();
});

afterAll(() => {
  server.close();
});

Using Presets

handlers.useMock({
  method: 'get',
  path: '/api/user',
  preset: 'success',
  override: ({ data }) => {
    data.name = 'Jane'; // Modify response data
  },
});

Using Profiles

const profiles = handlers.createMockProfiles(
  {
    name: 'Empty State',
    actions: ({ useMock }) => {
      useMock({
        method: 'get',
        path: '/api/user',
        preset: 'success',
        override: ({ data }) => {
          data.name = 'New User';
        },
      });
    },
  },
  {
    name: 'Error State',
    actions: ({ useMock }) => {
      useMock({
        method: 'get',
        path: '/api/user',
        preset: 'error',
      });
    },
  }
);

// Apply profile
profiles.useMock('Empty State');

🎯 Advanced Examples

Multiple Handlers with Type Safety

import { http } from 'msw-scenarios';
import { HttpResponse } from 'msw';

// User handler
const userHandler = http
  .get('/api/user', () => {
    return HttpResponse.json({ message: 'default' });
  })
  .presets(
    {
      label: 'authenticated',
      status: 200,
      response: {
        id: 1,
        name: 'John Doe',
        email: 'john@example.com',
        role: 'admin'
      }
    },
    {
      label: 'unauthorized',
      status: 401,
      response: {
        error: 'Unauthorized access'
      }
    }
  );

// Posts handler
const postsHandler = http
  .get('/api/posts', () => {
    return HttpResponse.json({ posts: [] });
  })
  .presets(
    {
      label: 'with-posts',
      status: 200,
      response: {
        posts: [
          { id: 1, title: 'First Post', content: 'Hello' },
          { id: 2, title: 'Second Post', content: 'World' }
        ],
        total: 2
      }
    },
    {
      label: 'empty',
      status: 200,
      response: { posts: [], total: 0 }
    }
  );

// Comments handler
const commentsHandler = http
  .get('/api/posts/:postId/comments', () => {
    return HttpResponse.json({ comments: [] });
  })
  .presets(
    {
      label: 'has-comments',
      status: 200,
      response: {
        comments: [
          { id: 1, text: 'Great post!', author: 'Jane' },
          { id: 2, text: 'Thanks!', author: 'John' }
        ]
      }
    },
    {
      label: 'no-comments',
      status: 200,
      response: { comments: [] }
    }
  );

// Combine all handlers
const handlers = extendHandlers(userHandler, postsHandler, commentsHandler);

// TypeScript provides excellent autocompletion and type checking:
handlers.useMock({
  method: 'get',      // ✨ Autocompletes with only available methods
  path: '/api/user',  // ✨ Autocompletes with only available paths
  preset: 'authenticated' // ✨ Autocompletes with only valid presets for this endpoint
  override: ({ data }) => {
    data.name = 'Jane Doe';  // ✨ TypeScript knows the shape of the response
    data.invalid = true;     // ❌ TypeScript Error: Property 'invalid' does not exist
  }
});

// Create comprehensive mocking profiles
const profiles = handlers.createMockProfiles(
  {
    name: 'Authenticated User with Content',
    actions: ({ useMock }) => {
      // ✨ Full type safety and autocompletion in profile actions
      useMock({
        method: 'get',
        path: '/api/user',
        preset: 'authenticated',
        override: ({ data }) => {
          data.name = 'Jane Doe'; // Type-safe override
        }
      });

      useMock({
        method: 'get',
        path: '/api/posts',
        preset: 'with-posts'
      });

      useMock({
        method: 'get',
        path: '/api/posts/:postId/comments',
        preset: 'has-comments'
      });
    }
  },
  {
    name: 'Unauthorized User',
    actions: ({ useMock, useRealAPI }) => {
      useMock({
        method: 'get',
        path: '/api/user',
        preset: 'unauthorized'
      });

      // Mix mock and real API calls
      useRealAPI({
        method: 'get',
        path: '/api/posts'  // ✨ Path autocomplete works here too
      });

      useMock({
        method: 'get',
        path: '/api/posts/:postId/comments',
        preset: 'no-comments'
      });
    }
  }
);

// Type-safe profile switching
profiles.useMock('Authenticated User with Content'); // ✨ Autocompletes available profile names

Jest Test Examples

import { HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import { http, extendHandlers, workerManager } from 'msw-scenarios';

describe('API Mocking Tests', () => {
  // Setup MSW server
  const server = setupServer();

  beforeAll(() => {
    workerManager.setupServer(server);
    server.listen({ onUnhandledRequest: 'error' });
  });

  afterEach(() => {
    workerManager.resetHandlers();
  });

  afterAll(() => {
    server.close();
  });

  it('should handle different API responses using presets', async () => {
    // Define handler with presets
    const userHandler = http
      .get('/api/user', () => {
        return HttpResponse.json({ message: 'default' });
      })
      .presets(
        {
          label: 'success',
          status: 200,
          response: { name: 'John', role: 'admin' },
        },
        {
          label: 'error',
          status: 404,
          response: { error: 'User not found' },
        }
      );

    // Create extended handlers
    const handlers = extendHandlers(userHandler);

    // Test success case
    handlers.useMock({
      method: 'get',
      path: '/api/user',
      preset: 'success',
    });

    let response = await fetch('/api/user');
    let data = await response.json();
    expect(data).toEqual({ name: 'John', role: 'admin' });

    // Test error case
    handlers.useMock({
      method: 'get',
      path: '/api/user',
      preset: 'error',
    });

    response = await fetch('/api/user');
    data = await response.json();
    expect(data).toEqual({ error: 'User not found' });
    expect(response.status).toBe(404);
  });

  it('should work with profiles for complex scenarios', async () => {
    const userHandler = http
      .get('/api/user', () => {
        return HttpResponse.json({ message: 'default' });
      })
      .presets(
        {
          label: 'authenticated',
          status: 200,
          response: { name: 'John', isAuthenticated: true },
        },
        {
          label: 'guest',
          status: 200,
          response: { name: 'Guest', isAuthenticated: false },
        }
      );

    const handlers = extendHandlers(userHandler);
    const profiles = handlers.createMockProfiles(
      {
        name: 'Authenticated User',
        actions: ({ useMock }) => {
          useMock({
            method: 'get',
            path: '/api/user',
            preset: 'authenticated',
          });
        },
      },
      {
        name: 'Guest User',
        actions: ({ useMock }) => {
          useMock({
            method: 'get',
            path: '/api/user',
            preset: 'guest',
          });
        },
      }
    );

    // Test authenticated profile
    profiles.useMock('Authenticated User');
    let response = await fetch('/api/user');
    let data = await response.json();
    expect(data.isAuthenticated).toBe(true);

    // Test guest profile
    profiles.useMock('Guest User');
    response = await fetch('/api/user');
    data = await response.json();
    expect(data.isAuthenticated).toBe(false);
  });
});

🎨 UI Integration

State Management API

import { mockingState } from 'msw-scenarios';

// Get current status
const status = mockingState.getCurrentStatus();

// Subscribe to changes
const unsubscribe = mockingState.subscribeToChanges(
  ({ mockingStatus, currentProfile }) => {
    console.log('Status:', mockingStatus);
    console.log('Profile:', currentProfile);
  }
);

// Control mocks
mockingState.resetEndpoint('get', '/api/user');
mockingState.resetAll();

React Integration Example

import { useEffect, useState } from 'react';
import { mockingState } from 'msw-scenarios';
import type { MockingStatus } from 'msw-scenarios';

function MockingController({ handlers, profiles }) {
  const [status, setStatus] = useState<MockingStatus[]>([]);
  const [currentProfile, setCurrentProfile] = useState<string | null>(null);

  useEffect(() => {
    return mockingState.subscribeToChanges(
      ({ mockingStatus, currentProfile }) => {
        setStatus(mockingStatus);
        setCurrentProfile(currentProfile);
      }
    );
  }, []);

  return (
    <div className="mocking-controller">
      {/* Profile Selector */}
      <div>
        <h3>Profiles</h3>
        <select
          value={currentProfile ?? ''}
          onChange={(e) => profiles.useMock(e.target.value)}
        >
          <option value="">No Profile</option>
          {profiles.getAvailableProfiles().map((name) => (
            <option key={name} value={name}>
              {name}
            </option>
          ))}
        </select>
      </div>

      {/* Endpoint Controls */}
      <div>
        <h3>Endpoints</h3>
        {status.map(({ method, path, currentPreset }) => (
          <div key={`${method}-${path}`} className="endpoint-control">
            <div>
              {method.toUpperCase()} {path}
            </div>
            <div>
              <select
                value={currentPreset ?? ''}
                onChange={(e) => {
                  if (e.target.value === '') {
                    handlers.useRealAPI({ method, path });
                  } else {
                    handlers.useMock({
                      method,
                      path,
                      preset: e.target.value,
                    });
                  }
                }}
              >
                <option value="">Real API</option>
                {handlers.handlers
                  .find((h) => h._method === method && h._path === path)
                  ?._presets.map((preset) => (
                    <option key={preset.label} value={preset.label}>
                      {preset.label}
                    </option>
                  ))}
              </select>
              <button onClick={() => mockingState.resetEndpoint(method, path)}>
                Reset
              </button>
            </div>
          </div>
        ))}
        <button onClick={mockingState.resetAll}>Reset All</button>
      </div>
    </div>
  );
}

📝 Types

The library provides full TypeScript support with the following key types:

interface MockingState {
  getCurrentStatus: () => Array<MockingStatus>;
  getCurrentProfile: <Name extends string = string>() => Name | null;
  subscribeToChanges: <Name extends string = string>(
    callback: (state: {
      mockingStatus: Array<MockingStatus>;
      currentProfile: Name | null;
    }) => void
  ) => () => void;
  resetAll: () => void;
  resetEndpoint: (method: string, path: string) => void;
  getEndpointState: (
    method: string,
    path: string
  ) => SelectedPreset | undefined;
  setCurrentProfile: <Name extends string = string>(
    profileName: Name | null
  ) => void;
}

📄 License

MIT

🤝 Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

0.1.10

9 months ago

0.1.0

10 months ago

0.1.2

10 months ago

0.2.0

9 months ago

0.1.1

10 months ago

0.1.8

10 months ago

0.1.7

10 months ago

0.1.9

9 months ago

0.1.4

10 months ago

0.1.3

10 months ago

0.1.6

10 months ago

0.1.5

10 months ago

0.0.4

12 months ago

0.0.3

12 months ago

0.0.2

12 months ago

0.0.1

12 months ago

1.0.1

12 months ago

1.0.0

12 months ago