Code Monkey home page Code Monkey logo

zimic's Introduction

Zimic

TypeScript-first HTTP request mocking

npm   •   Docs   •   Examples   •   Issues   •   Roadmap

Note

🚧 This project is still experimental and under active development!

Check our roadmap to v1. Contributors and ideas are welcome!

Zimic is a lightweight, TypeScript-first HTTP request mocking library, inspired by Zod's type inference and using MSW under the hood.

Features

Zimic provides a simple, flexible and type-safe way to mock HTTP requests.

  • Statically-typed mocks. Declare your HTTP endpoints and get full type inference and validation when applying mocks.
  • 🔗 Network-level intercepts. Internally, Zimic uses MSW, which intercepts HTTP requests right before they leave your app. This means that no parts of your code are stubbed or skipped. From you application's point of view, the mocked requests are indistinguishable from the real ones.
  • 🔧 Flexibility. You can simulate real application workflows by mocking each necessary endpoints. This is specially useful in testing, making sure the real path your application takes is covered and the sent requests are correct.
  • 💡 Simplicity. Zimic was designed from scratch to encourage clarity, simplicity and standardization in your mocks. Check our Getting started guide and starting mocking!

Table of contents

Getting started

1. Requirements

  • TypeScript >=4.7

  • strict mode enabled in your tsconfig.json:

    {
      // ...
      "compilerOptions": {
        // ...
        "strict": true,
      },
    }

2. Install from npm

Manager Command
npm npm install zimic --save-dev
yarn yarn add zimic --dev
pnpm pnpm add zimic --dev
bun bun add zimic --dev

Canary versions are released under the canary tag:

Manager Command
npm npm install zimic@canary --save-dev
yarn yarn add zimic@canary --dev
pnpm pnpm add zimic@canary --dev
bun bun add zimic@canary --dev

3. Post-install

Node.js post-install

No additional configuration is necessary for Node.js. Check out the usage guide and start mocking!

Browser post-install

To use Zimic on a browser, you should first initialize the mock service worker in your public directory:

npx zimic browser init <publicDirectory>

This will create a mockServiceWorker.js file in the provided public directory, which is necessary to intercept requests and mock responses. We recommend not deploying this file to production.

Usage

Examples

Visit our examples to see how to use Zimic with popular frameworks and libraries!

Basic usage

  1. To start using Zimic, create a worker targeting your platform.

    import { createHttpInterceptorWorker } from 'zimic/interceptor';
    
    const worker = createHttpInterceptorWorker({
      platform: 'node', // Or 'browser'
    });
  2. Then, create your first interceptor:

    import { JSONValue } from 'zimic';
    import { createHttpInterceptor } from 'zimic/interceptor';
    
    type User = JSONValue<{
      username: string;
    }>;
    
    const interceptor = createHttpInterceptor<{
      '/users': {
        GET: {
          response: {
            200: { body: User[] };
          };
        };
      };
    }>({
      worker,
      baseURL: 'http://localhost:3000',
    });

    In this example, we're creating an interceptor for a service with a single path, /users, that supports a GET method. The response for a successful request is an array of User objects, which is checked to be a valid JSON using JSONValue. Learn more at Declaring service schemas.

  3. Finally, start the worker to intercept requests:

    await worker.start();
  4. Now, you can start intercepting requests and returning mock responses!

    const listHandler = interceptor.get('/users').respond({
      status: 200,
      body: [{ username: 'diego-aquino' }],
    });
    
    const response = await fetch('http://localhost:3000/users');
    const users = await response.json();
    console.log(users); // [{ username: 'diego-aquino' }]

More usage examples and recommendations are available at zimic/interceptor API and Examples.

Testing

We recommend managing the lifecycle of your workers and interceptors using beforeAll and afterAll hooks in your test setup file. An example using Jest/Vitest structure:

  1. Create a worker:

    tests/interceptors/worker.ts

    import { createHttpInterceptorWorker } from 'zimic/interceptor';
    
    const worker = createHttpInterceptorWorker({
      platform: 'node', // Or 'browser'
    });
    
    export default worker;
  2. Create interceptors for your services:

    tests/interceptors/userInterceptor.ts

    import { createHttpInterceptor } from 'zimic/interceptor';
    import worker from './worker';
    
    const userInterceptor = createHttpInterceptor<{
      // User service schema
    }>({
      worker,
      baseURL: 'http://localhost:3000',
    });
    
    export default userInterceptor;

    tests/interceptors/analyticsInterceptor.ts

    import { createHttpInterceptor } from 'zimic/interceptor';
    import worker from './worker';
    
    const analyticsInterceptor = createHttpInterceptor<{
      // Analytics service schema
    }>({
      worker,
      baseURL: 'http://localhost:3001',
    });
    
    export default analyticsInterceptor;
  3. Create a setup file to manage lifecycle of the worker and the interceptors:

    tests/setup.ts

    import userInterceptor from './interceptors/userInterceptor';
    import analyticsInterceptor from './interceptors/analyticsInterceptor';
    
    beforeAll(async () => {
      // Start intercepting requests
      await worker.start();
    });
    
    beforeEach(async () => {
      // Clear all interceptors to make sure no tests affect each other
      userInterceptor.clear();
      analyticsInterceptor.clear();
    });
    
    afterAll(async () => {
      // Stop intercepting requests
      await worker.stop();
    });

zimic API

This module provides general resources, such as HTTP classes and types.

Tip

All APIs are documented using JSDoc, so you can view detailed descriptions directly in your IDE.

HttpHeaders

A superset of the built-in Headers class, with a strictly-typed schema. HttpHeaders is fully compatible with Headers and is used by Zimic to provide type safety when applying mocks.

import { HttpHeaders } from 'zimic';

const headers = new HttpHeaders<{
  accept?: string;
  'content-type'?: string;
}>({
  accept: '*/*',
  'content-type': 'application/json',
});

const contentType = headers.get('content-type');
console.log(contentType); // 'application/json'

Comparing HttpHeaders

HttpHeaders also provide the utility methods .equals and .contains, useful in comparisons with other headers:

import { HttpSchema, HttpHeaders } from 'zimic';

type HeaderSchema = HttpSchema.Headers<{
  accept?: string;
  'content-type'?: string;
}>;

const headers1 = new HttpHeaders<HeaderSchema>({
  accept: '*/*',
  'content-type': 'application/json',
});

const headers2 = new HttpHeaders<HeaderSchema>({
  accept: '*/*',
  'content-type': 'application/json',
});

const headers3 = new HttpHeaders<
  HeaderSchema & {
    'x-custom-header'?: string;
  }
>({
  accept: '*/*',
  'content-type': 'application/json',
  'x-custom-header': 'value',
});

console.log(headers1.equals(headers2)); // true
console.log(headers1.equals(headers3)); // false

console.log(headers1.contains(headers2)); // true
console.log(headers1.contains(headers3)); // false
console.log(headers3.contains(headers1)); // true

HttpSearchParams

A superset of the built-in URLSearchParams class, with a strictly-typed schema. HttpSearchParams is fully compatible with URLSearchParams and is used by Zimic to provide type safety when applying mocks.

import { HttpSearchParams } from 'zimic';

const searchParams = new HttpSearchParams<{
  names?: string[];
  page?: `${number}`;
}>({
  names: ['user 1', 'user 2'],
  page: '1',
});

const names = searchParams.getAll('names');
console.log(names); // ['user 1', 'user 2']

const page = searchParams.get('page');
console.log(page); // '1'

Comparing HttpSearchParams

HttpSearchParams also provide the utility methods .equals and .contains, useful in comparisons with other search params:

import { HttpSchema, HttpSearchParams } from 'zimic';

type SearchParamsSchema = HttpSchema.SearchParams<{
  names?: string[];
  page?: `${number}`;
}>;

const searchParams1 = new HttpSearchParams<SearchParamsSchema>({
  names: ['user 1', 'user 2'],
  page: '1',
});

const searchParams2 = new HttpSearchParams<SearchParamsSchema>({
  names: ['user 1', 'user 2'],
  page: '1',
});

const searchParams3 = new HttpSearchParams<
  SearchParamsSchema & {
    orderBy?: `${'name' | 'email'}.${'asc' | 'desc'}[]`;
  }
>({
  names: ['user 1', 'user 2'],
  page: '1',
  orderBy: ['name.asc'],
});

console.log(searchParams1.equals(searchParams2)); // true
console.log(searchParams1.equals(searchParams3)); // false

console.log(searchParams1.contains(searchParams2)); // true
console.log(searchParams1.contains(searchParams3)); // false
console.log(searchParams3.contains(searchParams1)); // true

zimic/interceptor API

This module provides a set of resources to create HTTP interceptors for both Node.js and browser environments.

Tip

All APIs are documented using JSDoc, so you can view detailed descriptions directly in your IDE.

HttpInterceptorWorker

Workers are used by HttpInterceptor's to intercept HTTP requests and return mock responses. To start intercepting requests, the worker must be started.

In a project, all interceptors can share the same worker.

createHttpInterceptorWorker

Creates an HTTP interceptor worker. A platform is required to specify the environment where the worker will be running:

  • Node.js:

    import { createHttpInterceptorWorker } from 'zimic/interceptor';
    
    const worker = createHttpInterceptorWorker({ platform: 'node' });
  • Browser:

    import { createHttpInterceptorWorker } from 'zimic/interceptor';
    
    const worker = createHttpInterceptorWorker({ platform: 'browser' });

worker.platform()

Returns the platform used by the worker (browser or node).

const platform = worker.platform();

worker.start()

Starts the worker, allowing it to be used by interceptors.

await worker.start();

When targeting a browser environment, make sure to run npx zimic browser init <publicDirectory> on your terminal before starting the worker. This initializes the mock service worker in your public directory. Learn more at zimic browser init <publicDirectory.

worker.stop()

Stops the worker, preventing it from being used by interceptors.

await worker.stop();

worker.isRunning()

Returns whether the worker is currently running and ready to use.

const isRunning = worker.isRunning();

HttpInterceptor

HTTP interceptors provide the main API to handle HTTP requests and return mock responses. The methods, paths, status codes, parameters, and responses are statically-typed based on the service schema. To intercept HTTP requests, an interceptor needs a running HttpInterceptorWorker.

Each interceptor represents a service and can be used to mock its paths and methods.

createHttpInterceptor

Creates an HTTP interceptor, the main interface to intercept HTTP requests and return responses. Learn more at Declaring service schemas.

import { JSONValue } from 'zimic';
import { createHttpInterceptorWorker, createHttpInterceptor } from 'zimic/interceptor';

const worker = createHttpInterceptorWorker({ platform: 'node' });

type User = JSONValue<{
  username: string;
}>;

const interceptor = createHttpInterceptor<{
  '/users/:id': {
    GET: {
      response: {
        200: { body: User };
      };
    };
  };
}>({
  worker,
  baseURL: 'http://localhost:3000',
});

Declaring service schemas

HTTP service schemas define the structure of the real services being used. This includes paths, methods, request and response bodies, and status codes. Based on the schema, interceptors will provide type validation when applying mocks.

An example of a complete interceptor schema:
import { HttpSchema, JSONValue } from 'zimic';
import { createHttpInterceptor } from 'zimic/interceptor';

// Declaring base types
type User = JSONValue<{
  username: string;
}>;

type UserCreationBody = JSONValue<{
  username: string;
}>;

type NotFoundError = JSONValue<{
  message: string;
}>;

type UserListSearchParams = HttpSchema.SearchParams<{
  name?: string;
  orderBy?: `${'name' | 'email'}.${'asc' | 'desc'}`[];
}>;

// Creating the interceptor
const interceptor = createHttpInterceptor<{
  '/users': {
    POST: {
      request: {
        headers: { accept: string };
        body: UserCreationBody;
      };
      response: {
        201: {
          headers: { 'content-type': string };
          body: User;
        };
      };
    };
    GET: {
      request: {
        searchParams: UserListSearchParams;
      };
      response: {
        200: { body: User[] };
        404: { body: NotFoundError };
      };
    };
  };

  '/users/:id': {
    GET: {
      response: {
        200: { body: User };
        404: { body: NotFoundError };
      };
    };
  };
}>({
  worker,
  baseURL: 'http://localhost:3000',
});
Alternatively, you can compose the schema using utility types:
import { HttpSchema, JSONValue } from 'zimic';
import { createHttpInterceptor } from 'zimic/interceptor';

// Declaring the base types
type User = JSONValue<{
  username: string;
}>;

type UserCreationBody = JSONValue<{
  username: string;
}>;

type NotFoundError = JSONValue<{
  message: string;
}>;

type UserListSearchParams = HttpSchema.SearchParams<{
  name?: string;
  orderBy?: `${'name' | 'email'}.${'asc' | 'desc'}`[];
}>;

// Declaring user methods
type UserMethods = HttpSchema.Methods<{
  POST: {
    request: {
      headers: { accept: string };
      body: UserCreationBody;
    };
    response: {
      201: {
        headers: { 'content-type': string };
        body: User;
      };
    };
  };

  GET: {
    request: {
      searchParams: UserListSearchParams;
    };
    response: {
      200: { body: User[] };
      404: { body: NotFoundError };
    };
  };
}>;

type UserGetByIdMethods = HttpSchema.Methods<{
  GET: {
    response: {
      200: { body: User };
      404: { body: NotFoundError };
    };
  };
}>;

// Declaring user paths
type UserPaths = HttpSchema.Paths<{
  '/users': UserMethods;
}>;

type UserByIdPaths = HttpSchema.Paths<{
  '/users/:id': UserGetByIdMethods;
}>;

// Declaring interceptor schema
type ServiceSchema = UserPaths & UserByIdPaths;

// Creating the interceptor
const interceptor = createHttpInterceptor<ServiceSchema>({
  worker,
  baseURL: 'http://localhost:3000',
});
Declaring paths

At the root level, each key represents a path or route of the service:

import { createHttpInterceptor } from 'zimic/interceptor';

const interceptor = createHttpInterceptor<{
  '/users': {
    // Path schema
  };
  '/users/:id': {
    // Path schema
  };
  '/posts': {
    // Path schema
  };
}>({
  worker,
  baseURL: 'http://localhost:3000',
});
Alternatively, you can also compose paths using the utility type HttpSchema.Paths:
import { HttpSchema } from 'zimic';
import { createHttpInterceptor } from 'zimic/interceptor';

type UserPaths = HttpSchema.Paths<{
  '/users': {
    // Path schema
  };
  '/users/:id': {
    // Path schema
  };
}>;

type PostPaths = HttpSchema.Paths<{
  '/posts': {
    // Path schema
  };
}>;

const interceptor = createHttpInterceptor<UserPaths & PostPaths>({
  worker,
  baseURL: 'http://localhost:3000',
});
Declaring methods

Each path can have one or more methods, (GET, POST, PUT, PATCH, DELETE, HEAD, and OPTIONS). The method names are case-sensitive.

import { createHttpInterceptor } from 'zimic/interceptor';

const interceptor = createHttpInterceptor<{
  '/users': {
    GET: {
      // Method schema
    };
    POST: {
      // Method schema
    };
  };
  // Other paths
}>({
  worker,
  baseURL: 'http://localhost:3000',
});
Similarly to paths, you can also compose methods using the utility type HttpSchema.Methods:
import { HttpSchema } from 'zimic';
import { createHttpInterceptor } from 'zimic/interceptor';

type UserMethods = HttpSchema.Methods<{
  GET: {
    // Method schema
  };
  POST: {
    // Method schema
  };
}>;

const interceptor = createHttpInterceptor<{
  '/users': UserMethods;
}>({
  worker,
  baseURL: 'http://localhost:3000',
});
Declaring requests

Each method can have a request, which defines the schema of the accepted requests. headers, searchParams, and body are supported to provide type safety when applying mocks.

import { HttpSchema, JSONValue } from 'zimic';
import { createHttpInterceptor } from 'zimic/interceptor';

type UserCreationBody = JSONValue<{
  username: string;
}>;

type UserListSearchParams = HttpSchema.SearchParams<{
  username?: string;
}>;

const interceptor = createHttpInterceptor<{
  '/users': {
    POST: {
      request: {
        body: UserCreationBody;
      };
      // ...
    };

    GET: {
      request: {
        searchParams: UserListSearchParams;
      };
      // ...
    };
    // Other methods
  };
  // Other paths
}>({
  worker,
  baseURL: 'http://localhost:3000',
});

Tip

You only need to include in the schema the properties you want to use in your mocks. Headers, search params, or body fields that are not used do not need to be declared, keeping your type definitions clean and concise.

Important

Body types cannot be declared using the keyword interface, because interfaces do not have implicit index signatures as types do. Part of Zimic's JSON validation relies on index signatures. To workaround this, you can declare bodies using type. As an extra step to make sure the type is a valid JSON, you can use the utility type JSONValue.

You can also compose requests using the utility type HttpSchema.Request, similarly to methods:
import { HttpSchema, JSONValue } from 'zimic';
import { createHttpInterceptor } from 'zimic/interceptor';

type UserCreationBody = JSONValue<{
  username: string;
}>;

type UserCreationRequest = HttpSchema.Request<{
  body: UserCreationBody;
}>;

const interceptor = createHttpInterceptor<{
  '/users': {
    POST: {
      request: UserCreationRequest;
    };
  };
}>({
  worker,
  baseURL: 'http://localhost:3000',
});
Declaring responses

Each method can also have a response, which defines the schema of the returned responses. The status codes are used as keys. headers and body are supported to provide type safety when applying mocks.

import { JSONValue } from 'zimic';
import { createHttpInterceptor } from 'zimic/interceptor';

type User = JSONValue<{
  username: string;
}>;

type NotFoundError = JSONValue<{
  message: string;
}>;

const interceptor = createHttpInterceptor<{
  '/users/:id': {
    GET: {
      // ...
      response: {
        200: { body: User };
        404: { body: NotFoundError };
      };
    };
    // Other methods
  };
  // Other paths
}>({
  worker,
  baseURL: 'http://localhost:3000',
});

Tip

Similarly to Declaring requests, you only need to include in the schema the properties you want to use in your mocks. Headers, search params, or body fields that are not used do not need to be declared, keeping your type definitions clean and concise.

Important

Also similarly to Declaring requests, body types cannot be declared using the keyword interface, because interfaces do not have implicit index signatures as types do. Part of Zimic's JSON validation relies on index signatures. To workaround this, you can declare bodies using type. As an extra step to make sure the type is a valid JSON, you can use the utility type JSONValue.

You can also compose responses using the utility types HttpSchema.ResponseByStatusCode and HttpSchema.Response, similarly to requests:
import { HttpSchema, JSONValue } from 'zimic';
import { createHttpInterceptor } from 'zimic/interceptor';

type User = JSONValue<{
  username: string;
}>;

type NotFoundError = JSONValue<{
  message: string;
}>;

type SuccessUserGetResponse = HttpSchema.Response<{
  body: User;
}>;

type NotFoundUserGetResponse = HttpSchema.Response<{
  body: NotFoundError;
}>;

type UserGetResponses = HttpSchema.ResponseByStatusCode<{
  200: SuccessUserGetResponse;
  404: NotFoundUserGetResponse;
}>;

const interceptor = createHttpInterceptor<{
  '/users/:id': {
    GET: {
      response: UserGetResponses;
    };
  };
}>({
  worker,
  baseURL: 'http://localhost:3000',
});

interceptor.baseURL()

Returns the base URL of the interceptor.

const baseURL = interceptor.baseURL();

interceptor.<method>(path)

Creates an HttpRequestHandler for the given method and path. The path and method must be declared in the interceptor schema.

The supported methods are: get, post, put, patch, delete, head, and options.

import { createHttpInterceptor } from 'zimic/interceptor';

const interceptor = createHttpInterceptor<{
  '/users': {
    GET: {
      response: {
        200: { body: User[] };
      };
    };
  };
}>({
  worker,
  baseURL: 'http://localhost:3000',
});

// Intercept any GET requests to http://localhost:3000/users and return this response
const listHandler = interceptor.get('/users').respond({
  status: 200
  body: [{ username: 'diego-aquino' }],
});
Dynamic path parameters

Paths with dynamic path parameters, such as /users/:id, are supported, but you need to specify the original path as a type parameter to get type validation.

import { createHttpInterceptor } from 'zimic/interceptor';

const interceptor = createHttpInterceptor<{
  '/users/:id': {
    PUT: {
      request: {
        body: {
          username: string;
        };
      };
      response: {
        204: {};
      };
    };
  };
}>({
  worker,
  baseURL: 'http://localhost:3000',
});

interceptor.get('/users/:id'); // Matches any id
interceptor.get<'/users/:id'>(`/users/${1}`); // Only matches id 1 (notice the original path as a type parameter)

interceptor.clear()

Clears all of the HttpRequestHandler instances created by this interceptor, including their registered responses and intercepted requests. After calling this method, the interceptor will no longer intercept any requests until new mock responses are registered.

This method is useful to reset the interceptor mocks between tests.

interceptor.clear();

HttpRequestHandler

HTTP request handlers allow declaring responses to return for matched intercepted requests. They also keep track of the intercepted requests and their responses, allowing checks about how many requests your application made and with which parameters.

When multiple handlers match the same method and path, the last created with interceptor.<method>(path) will be used.

handler.method()

Returns the method that matches this handler.

const handler = interceptor.post('/users');
const method = handler.method();
console.log(method); // 'POST'

handler.path()

Returns the path that matches this handler. The base URL of the interceptor is not included, but it is used when matching requests.

const handler = interceptor.get('/users');
const path = handler.path();
console.log(path); // '/users'

handler.with(restriction)

Declares a restriction to intercepted request matches. headers, searchParams, and body are supported to limit which requests will match the handler and receive the mock response. If multiple restrictions are declared, either in a single object or with multiple calls to .with(), all of them must be met, essentially creating an AND condition.

Static restrictions
const creationHandler = interceptor
  .post('/users')
  .with({
    headers: { 'content-type': 'application/json' },
    body: creationPayload,
  })
  .respond({
    status: 200,
    body: [{ username: 'diego-aquino' }],
  });

By default, restrictions use exact: false, meaning that any request containing the declared restrictions will match the handler, regardless of having more properties or values. In the example above, requests with more headers than content-type: application/json will still match the handler. The same applies to search params and body restrictions.

If you want to match only requests with the exact values declared, you can use exact: true:

const creationHandler = interceptor
  .post('/users')
  .with({
    headers: { 'content-type': 'application/json' },
    body: creationPayload,
    exact: true, // Only requests with these exact headers and body will match
  })
  .respond({
    status: 200,
    body: [{ username: 'diego-aquino' }],
  });
Computed restrictions

A function is also supported to declare restrictions, in case they are dynamic.

const creationHandler = interceptor
  .post('/users')
  .with((request) => {
    const accept = request.headers.get('accept');
    return accept !== null && accept.startsWith('application');
  })
  .respond({
    status: 200,
    body: [{ username: 'diego-aquino' }],
  });

The request parameter represents the intercepted request, containing useful properties such as .body, .headers, and .searchParams, which are typed based on the interceptor schema. The function should return a boolean: true if the request matches the handler and should receive the mock response; false otherwise and the request should bypass the handler.

handler.respond(declaration)

Declares a response to return for matched intercepted requests.

When the handler matches a request, it will respond with the given declaration. The response type is statically validated against the schema of the interceptor.

Static responses
const listHandler = interceptor.get('/users').respond({
  status: 200,
  body: [{ username: 'diego-aquino' }],
});
Computed responses

A function is also supported to declare a response, in case it is dynamic:

const listHandler = interceptor.get('/users').respond((request) => {
  const username = request.searchParams.get('username');
  return {
    status: 200,
    body: [{ username }],
  };
});

The request parameter represents the intercepted request, containing useful properties such as .body, .headers, and .searchParams, which are typed based on the interceptor schema.

handler.bypass()

Clears any response declared with .respond(declaration), making the handler stop matching requests. The next handler, created before this one, that matches the same method and path will be used if present. If not, the requests of the method and path will not be intercepted.

To make the handler match requests again, register a new response with handler.respond().

This method is useful to skip a handler. It is more gentle than handler.clear(), as it only removed the response, keeping restrictions and intercepted requests.

const listHandler1 = interceptor.get('/users').respond({
  status: 200,
  body: [],
});

const listHandler2 = interceptor.get('/users').respond({
  status: 200,
  body: [{ username: 'diego-aquino' }],
});

listHandler2.bypass();
// Now, GET requests to /users will match listHandler1 and return an empty array

listHandler2.requests(); // Still contains the intercepted requests up to the bypass

handler.clear()

Clears any response declared with .respond(declaration), restrictions declared with .with(restriction), and intercepted requests, making the handler stop matching requests. The next handler, created before this one, that matches the same method and path will be used if present. If not, the requests of the method and path will not be intercepted.

To make the handler match requests again, register a new response with handler.respond().

This method is useful to reset handlers to a clean state between tests. It is more aggressive than handler.bypass(), as it also clears restrictions and intercepted requests.

const listHandler1 = interceptor.get('/users').respond({
  status: 200,
  body: [],
});

const listHandler2 = interceptor.get('/users').respond({
  status: 200,
  body: [{ username: 'diego-aquino' }],
});

listHandler2.clear();
// Now, GET requests to /users will match listHandler1 and return an empty array

listHandler2.requests(); // Now empty

handler.requests()

Returns the intercepted requests that matched this handler, along with the responses returned to each of them. This is useful for testing that the correct requests were made by your application.

const updateHandler = interceptor.put('/users/:id').respond((request) => {
  const newUsername = request.body.username;
  return {
    status: 200,
    body: [{ username: newUsername }],
  };
});

await fetch(`http://localhost:3000/users/${1}`, {
  method: 'PUT',
  body: JSON.stringify({ username: 'new' }),
});

const updateRequests = updateHandler.requests();
expect(updateRequests).toHaveLength(1);
expect(updateRequests[0].url).toEqual('http://localhost:3000/users/1');
expect(updateRequests[0].body).toEqual({ username: 'new' });

The return by requests are simplified objects based on the Request and Response web APIs, containing an already parsed body in .body, typed headers in .headers and typed search params in .searchParams.

If you need access to the original Request and Response objects, you can use the .raw property:

const listRequests = listHandler.requests();
console.log(listRequests[0].raw); // Request{}
console.log(listRequests[0].response.raw); // Response{}

CLI

zimic --version

Displays the current version of Zimic.

zimic --help

Displays the available commands and options.

zimic browser init <publicDirectory>

Initializes the mock service worker in a public directory.

This command is necessary when using Zimic in a browser environment. It creates a mockServiceWorker.js file in the provided public directory, which is used to intercept requests and mock responses.

If you are using Zimic mainly in tests, we recommend adding the mockServiceWorker.js to your .gitignore and adding this command to a postinstall scripts in your package.json. This ensures that the latest service worker script is being used after upgrading Zimic.


Changelog

The changelog is available on our GitHub Releases page.

Development

Interested in contributing to Zimic? View our contributing guide.

zimic's People

Contributors

diego-aquino avatar

Stargazers

Pedro Henrique avatar  avatar Emanuel Moura avatar irvile avatar

Watchers

 avatar

zimic's Issues

Custom unhandled request strategies

  • An HTTP interceptor should support:
    • A custom unhandled request strategy: 'ignore' | 'warn' or a function receiving a request. The default is 'warn'.
    • When the modes are 'ignore' and 'warn', output structured logs containing all relevant fields of the request, to help developers know which specific requests were not matched. For example:
      [zimic] Request did not match any filters: Request { headers: { 'content-type': 'application/json' }, body: 'message' }
      

HTTP mock server

Discussion: #75

Consider removing the platform parameter from worker factories. We can infer the platform automatically.

  • Create local and remote interceptors
  • Create local and remote workers
  • Create local and remote trackers
  • Create websocket integration
  • Create server
  • Create server CLI
  • Integrate remote interceptors with mock server
  • Consider not using records and maps to store the http handlers
  • Support mock urls with dynamic paths
  • Start the zimic mock server outside the tests on browser runs
  • Wait for websocket messages to be sent on browser
  • Consider renaming mockServerURL to serverURL
  • Use ZIMIC_SERVER_PORT in the test command of zimic-test-client
  • Fix worker tests
  • Fix tracker tests
  • Fix interceptor tests
  • Fix other tests
  • Fix test-client tests
  • Remove comments from the default.ts test file
  • Update zimic server start command to pass other arguments to the on-ready command
    zimic server start --port 3000 --ephemeral -- pnpm test
  • Do not show server CLI help if the --on-ready command fails
  • Use https://www.npmjs.com/package/cross-spawn in server on ready command
  • Remove 0 access control max age on build
  • Add type tests preventing the use of bodies in methods with no bodies
  • Test coverage
    • Add tests to the server implementation as necessary
    • Add tests to the websocket implementation as necessary
    • Add tests to CLI implementation as necessary
    • Recomplete 100% test coverage
    • Replace skipped tests by ifs
  • Add logs to mock server
  • Handle timeouts in server event listener promises.
  • Handle timeouts in websocket integration.
  • Consider splitting worker and request tracker tests

  • Validate that the versions between the zimic client and the zimic server are equal on connection.
  • Think about easing the migration from local to remote (users have to add await to every mock applied)
    • Codemod?

Release action setup

  • Create partial release on GitHub after a (protected) partial tag is created (e.g. v0.1.0-canary.0)
  • Create full release on GitHub after a (protected) full tag is created (e.g. v0.1.0)
  • Create CLI to upgrade versions and create tags and release branches automatically
  • Create NPM release action (partial and full versions)

Expectation about no unused handlers

  • The HTTP interceptor should support:
    • A method to throw an error if not all handlers were used:
      expectNoUnusedHandlers(): void;
      Example:
      afterAll(() => {
        authInterceptor.expectNoUnusedHandlers();
      });

v0.3.0 documentation

  • Document API changes introduced in v0.3.0
    • README.md
    • Code documentation with JSDoc
  • Add examples to headline links to README
  • Change examples in README to follow the new strict JSON validation.

HTTP interceptor request match by body

  • The HTTP request tracker should support:
    • Defining a body to match requests
    • Receiving a second, optional argument to whether the match should be exact or not (default: false)
      const creationTracker = authInterceptor
        .post('/users')
        .with({
          body: creationPayload,
        }, { exact: true })
        .respond({
          status: 201,
          body: user,
        });

Service schema typegen brainstorming

Initial ideas

Alternative 1

Utility package to be installed in client apps, supporting schema typegen generation by CLI

  • Workflow:
    1. [mocked service] Install the utility package and export the types of all endpoints, using a terminal command
    2. [intermediate package] Commit/upload typegen files to a common package
    3. [tested service] Install the intermediate package and import the typegen to use in the HTTP interceptor factories
  • Notices:
    • Dependent of frameworks used in the mocked service (many adapters are necessary)
    • Dependent of programming languages used in the mocked service (many implementations are necessary)

Alternative 2

Utility package to be installed in client apps, supporting schema typegen generation by using the app

  • Workflow:
    1. [tested service] Install the utility package and use the service normally
    2. [tested service] The utility package tracks the requests being made and saves the found types to a typegen file
    3. [tested service] Import the typegen to use in the HTTP interceptor factories
  • Notices:
    • Independent of frameworks used in the mocked service
    • Independent of programming languages used in the mocked service
    • Relies that the user runs the program "in real life", outside the mocked tests, such as e2e

Alternative 3

Runtime endpoint specification

  • Workflow:
    1. [mocked service] Install a utility package that generates the specification based on the endpoints (e.g. OpenAPI)
    2. [mocked service] Create an endpoint that serves the endpoint specification
    3. [tested service] Terminal command (or automatically before tests) fetches the specification endpoint and creates the typegen
    4. [tested service] Import the typegen to use in the HTTP interceptor factories
  • Notices:
    • Dependent of frameworks used in the mocked service
    • Dependent of programming languages used in the mocked service
    • HTTP-only if OpenAPI is used
    • Works the same as the Alternative 1, but excluding the intermediate package
    • Might be the most feasible solution (see https://github.com/drwpow/openapi-typescript)

HTTP interceptor defaults

  • The HTTP interceptor should support:
    • Defining default handlers, validated statically based on the service schema in the HTTP interceptor factory

Examples projects with third-party integrations (part 2)

Example projects

  • with-playwright
    • Next.js example using app router
    • README.md
  • with-next-js
    • App router example /app, tested with playwright
    • Pages router example /pages, tested with playwright
    • README.md
  • Move zimic to a development dependency in examples
  • Add instructions to clone each example
  • Add instructions to run each example

  • Improve example gitignore files
  • Try to improve types check and eslint to be more independent of the monorepo
  • Simplify example configs

HTTP interceptor request match by form data and blob

  • The HTTP request tracker should support:
    • Defining form data to match requests
    • Receiving a second, optional argument to whether the match should be exact or not (default: false)
      const creationTracker = authInterceptor
        .post('/users')
        .with({
          formData: creationPayload,
          body: blob, // or File or Buffer
        })
        .respond({
          status: 201,
          body: user,
        });

Base HTTP interceptor

  • The base HTTP interceptor should support:
    • A base URL
    • The HTTP methods GET, POST, PATCH, PUT, DELETE, HEAD, and OPTIONS
    • Bypassing unhandled requests by default
    • Being created using the factory createHttpInterceptor
    • A method to clear all handlers:
      clearHandlers: (options?: { includeDefaults?: boolean }) => void;
  • Omit internal atributes and methods from the exported types
    • Export classes only as types
    • Create factory createHttpInterceptor
  • Verify tracked requests in trackers and interceptors
  • Try to simplify/abstract the tests of http interceptors
  • Provide validator/utility types to declare service schemas incrementally (smaller types)
  • Provide utility types to extract schema data
  • Verify multiple trackers applied to the same requests (should use the last one without error)
  • Verify mocks using routes with dynamic route parameters

Support to body, search params, route params and form data will be added in the future.

Browser service worker init not working on NPM

> pnpm zimic browser init public
[zimic] Copying the service worker script to ~/zimic/apps/zimic-test-client/public...
cli.js browser init <publicDirectory>

Initialize the browser service worker

Positionals:
  publicDirectory  The path to the public directory of your application
                                                             [string] [required]

Options:
  --help     Show help                                                 [boolean]
  --version  Show version number                                       [boolean]

[Error: ENOENT: no such file or directory, copyfile '~/zimic/node_modules/.pnpm/[email protected][email protected]/node_modules/zimic/node_modules/msw/lib/mockServiceWorker.js' -> '~/www/zimic/apps/zimic-test-client/public/mockServiceWorker.js'] {
  errno: -2,
  code: 'ENOENT',
  syscall: 'copyfile',
  path: '~/www/zimic/node_modules/.pnpm/[email protected][email protected]/node_modules/zimic/node_modules/msw/lib/mockServiceWorker.js',
  dest: '~/www/zimic/apps/zimic-test-client/public/mockServiceWorker.js'
}

Examples projects with third-party integrations (part 1)

Example projects

  • with-vitest-node (typescript, fastify backend, vitest)
    • Example configuration and code
    • README
  • with-jest-node (typescript, fastify backend, jest)
    • Example configuration and code
    • README
  • with-vitest-browser (typescript, vanilla browser, vitest, browser mode)
    • Example configuration and code
    • README
  • with-vitest-jsdom (typescript, vanilla browser, vitest, jsdom)
    • Example configuration and code
    • README
  • with-jest-jsdom (typescript, jsdom, jest)
    • Example configuration and code
    • README

HTTP interceptor request match by path params

  • The HTTP request tracker should support:
    • Defining expanded route params to match requests
      const getTracker = authInterceptor
        .get('/users/:id')
        .with({ pathParams: { id: user.id } })
        .respond({
          status: 200,
          body: user,
        });
      
      // Code to test
      const response = await getUserById(user.id);
      
      expect(response.status).toBe(200);
      
      const returnedUsers = (await response.json()) as User[];
      expect(returnedUsers).toEqual([user]);
      
      const getRequests = getTracker.requests();
      expect(getRequests).toHaveLength(1);
      
      // Simpler alternative:
      // expect(getRequests[0].routeParams).toEqual({ id: user.id });
      
      expect(getRequests[0].routeParams.id).toBe(user.id);
    • Defining inline route params to match requests
      const getTracker = authInterceptor.get(`/users/${user.id}`).respond({
        status: 200,
        body: user,
      });
      
      // Code to test
      const response = await getUserById(user.id);
      
      expect(response.status).toBe(200);
      
      const returnedUsers = (await response.json()) as User[];
      expect(returnedUsers).toEqual([user]);
      
      const getRequests = getTracker.requests();
      expect(getRequests).toHaveLength(1);
      
      // Simpler alternative:
      // expect(getRequests[0].routeParams).toEqual({});
      
      expect(getRequests[0].routeParams.id).toBe(undefined);

How to use zimic to intercept Supabase Auth?

Hi, @diego-aquino

I'm trying to use zimic with supabase.

scenario:

const supabaseInterceptor = createHttpInterceptor<{
	'/auth/v1/admin/users': {
		POST: {
			request: {
				body: { email: '[email protected]'; password: '123456' }
			}
			response: {
				200: { body: { user: { id: '123' } } }
			}
		}
	}
}>({
	worker,
	baseURL: 'http://localhost:8000'
})

My supabase environtment:

SUPABASE ENVS

SUPABASE_URL=http://localhost:8000

Details:

node: 20.10.0
honojs: 4.0.0

Error message:

ConnectionRefused: Unable to connect. Is the computer able to access the url?
path: "http://localhost:8000/auth/v1/admin/users"

Keep going! amazing work here!

Allow reuse of request trackers after the interceptor is cleared

Marking request trackers as unusable after the interceptor is cleared makes sure no outdated interceptors are used. However, this strategy makes it impossible to reuse interceptors between tests:

const userListTracker = userInterceptor.get('/users')

beforeEach(async () => {
  userInterceptor.clear()
})

it('test 1', () => {
  userListTracker.respond(...) // won't work
})

it('test 2', () => {
  userListTracker.respond(...) // won't work
})

HTTP mock server ideas

Context

Zimic currently only applies HTTP mocks to the process where the interceptor is called. This makes it unusable in multi-process scenarios.

An example that illustrates this case:

  • A route handler of a Next.js application is being tested using Vitest.
  • The Next.js server was started as a separate process using next dev.
  • The Vitest tests make HTTP requests to the route handler running on localhost.
  • The route handler access an external service. This service is not guaranteed to be running all the time and, as we have no control of its availability, the tests should not reach it directly.

This is a great use case for Zimic, since mocking external applications with type safety is one of our goals. However, this scenario is not currently supported. For example, a test of this application could look like:

  1. The Vitest test initializes an interceptor and applies a mock to the external service. It is useful to keep the interceptor mock declarations in the test to easily simulate behaviors.
  2. The route handler running on the second process is not affected and requests makes by the Vitest test to the route handler still reach the external service.
Archived proposals

Proposal 1: mocks applied directly to the remote process

Solution

A feature that would extend Zimic to this use case would be the introduction of "remote HTTP interceptors". Remote interceptors are similar to our current interceptors, but they apply mocks to remote processes instead of the one the interceptor is initialized.

Using the example above, the workflow using remote interceptors would be like:

  1. The Next.js application would expose an endpoint (e.g. /zimic), available only in development and testing. This endpoint would be used by Zimic to receive mocks definitions and apply them to the Next.js process.
  2. The Vitest test would create a remote interceptor, linking it to the endpoint made available by the Next.js application.
  3. When applying mocks or doing any operation on the remote interceptor, a request would be issued to the endpoint on the Next.js application. In other words, declaring a mock in the Vitest test would actually send it to the Next,js application, where it would be applied. This would mean that all methods of remote interceptors and request trackers would need to be asynchronous.
  4. The external service would no longer be reached in tests, as the Vitest test would be able to apply mocks remotely to the server application.

API

// creates a RemoteHttpInterceptor
const remoteInterceptor = createHttpInterceptor<{ ... }>({
  type: 'remote' // 'local' is the default
  remoteURL: 'http://localhost:3000/zimic',
})

const getTracker = await remoteInterceptor.get('/').respond({
  status: 200,
  body: { success: true }
})

// mock applied to remote application

const getRequests = await getTracker.requests()
expect(getRequests).toHaveLenght(1)

Proposal 2: Zimic mock server

Solution

A feature that would extend Zimic to this use case would be the introduction of remote HTTP interceptors. Remote interceptors are similar to our current interceptors, but they apply mocks to a remote Zimic server process, instead of the process where the interceptor is initialized.

Using the example above, the workflow using remote interceptors would be like:

  1. In development or testing, a Zimic mock server would need to be started alongside the Next.js application. The Zimic server would be used to receive and apply mock definitions.
  2. The Next.js application would route requests intent to the external service to the Zimic server.
  3. The Vitest test would create a remote worker and interceptors, linking them to the Zimic mock server in use by the Next.js application.
  4. When doing any operation on the remote interceptor, such as declaring mocks, the operation would be sent to the Zimic server to apply it. In other words, declaring a mock in the Vitest test would actually send it to the Zimic server. This would allow simulating scenarios of the external service responses in tests. For this to work, all methods of remote interceptors and request trackers would need to be asynchronous.
  5. The external service would no longer be reached in tests, as the Vitest test would be able to apply mocks remotely to the Zimic mock server in use by the Next.js application. 🎉

API examples

Using local workers and interceptors:
type InterceptorSchema = HttpInterceptorSchema.Root<{
  '/': {
    GET: {
      response: {
        200: { body: { success: true } };
      };
    };
  };
}>;

const localWorker = createWorker({
  type: 'local',
});

await localWorker.start(); // starts the local worker, applies the mocks to the local process

// creates an HttpInterceptor
const localInterceptor = createHttpInterceptor<InterceptorSchema>({
  worker: localWorker,
  baseURL: 'http://localhost:3000',
});

// applying remote mocks is a asynchronous operation
const getTracker = localInterceptor.get('/').respond({
  status: 200,
  body: { success: true },
});

// mock applied to local process

const getRequests = getTracker.requests();
expect(getRequests).toHaveLength(1);
Using remote workers and interceptors:
type InterceptorSchema = HttpInterceptorSchema.Root<{
  '/': {
    GET: {
      response: {
        200: { body: { success: true } };
      };
    };
  };
}>;

const remoteWorker = createWorker({
  type: 'remote',
  serverURL: 'http://localhost:3001', // the URL of the zimic mock server
});

// start the zimic mock server with:
// zimic server start --port 3001 --on-ready 'npm test' --ephemeral true

await remoteWorker.start(); // starts the remote worker and links it to the zimic mock server

// creates a RemoteHttpInterceptor
const remoteInterceptor = createHttpInterceptor<InterceptorSchema>({
  worker: remoteWorker,
  // the application should use the base URL of the external service as http://localhost:3001/service1
  pathPrefix: '/service1',
});

// applying remote mocks is an asynchronous operation
const getTracker = await remoteInterceptor.get('/').respond({
  status: 200,
  body: { success: true },
});

// mock applied to remote server:
// any requests to http://localhost:3001 will be handled by the zimic mock server with the mock applied

// getting requests is also an asynchronous operation
const getRequests = await getTracker.requests();
expect(getRequests).toHaveLength(1);

Create v1 API draft examples

Example 1

import * as crypto from 'crypto';
import { createHttpInterceptor } from './lib';
import { describe, beforeEach, it, expect, afterAll } from '../common/test';

interface User {
  id: string;
  name: string;
  email: string;
}

interface UserWithPassword extends User {
  password: string;
}

type UserCreationPayload = Omit<UserWithPassword, 'id'>;

interface LoginResult {
  accessToken: string;
  refreshToken: string;
}

interface RequestError {
  code: string;
  message: string;
}

interface ValidationError extends RequestError {
  code: 'validation_error';
}

interface UnauthorizedError extends RequestError {
  code: 'unauthorized';
}

interface NotFoundError extends RequestError {
  code: 'not_found';
}

interface ConflictError extends RequestError {
  code: 'conflict';
}

const authInterceptor = createHttpInterceptor<{
  '/users': {
    POST: {
      request: {
        body: UserCreationPayload;
      };
      response: {
        201: { body: User };
        400: { body: ValidationError };
        409: { body: ConflictError };
      };
    };

    GET: {
      request: {
        searchParams: {
          name?: string;
          email?: string;
        };
      };
      response: {
        200: { body: User[] };
      };
    };
  };

  '/users/:id': {
    GET: {
      request: {
        routeParams: { id: string };
      };
      response: {
        200: { body: User };
        404: { body: NotFoundError };
      };
    };
    PATCH: {
      request: {
        headers: { authorization: string };
        routeParams: { id: string };
        body: Partial<User>;
      };
      response: {
        200: { body: User };
        404: { body: NotFoundError };
      };
    };
    DELETE: {
      request: {
        headers: { authorization: string };
        routeParams: { id: string };
      };
      response: {
        204: { body: void };
        404: { body: NotFoundError };
      };
    };
  };

  '/session/login': {
    POST: {
      request: {
        body: {
          email: string;
          password: string;
        };
      };
      response: {
        201: { body: LoginResult };
        400: { body: ValidationError };
        401: { body: UnauthorizedError };
      };
    };
  };

  '/session/refresh': {
    POST: {
      request: {
        body: { refreshToken: string };
      };
      response: {
        201: { body: LoginResult };
        401: { body: UnauthorizedError };
      };
    };
  };

  '/session/logout': {
    POST: {
      request: {
        headers: { authorization: string };
      };
      response: {
        204: { body: void };
        401: { body: UnauthorizedError };
      };
    };
  };
}>({
  baseURL: 'https://localhost:3000',
  unhandledRequestStrategy: 'error',
  defaults: {
    '/users': {
      GET: {
        response: {
          status: 200,
          body: [],
        },
      },
    },

    '/users/:id': {
      GET: {
        response: {
          status: 404,
          body: {
            code: 'not_found',
            message: 'User not found',
          },
        },
      },
    },
  },
});

describe('Users', () => {
  const user: User = {
    id: crypto.randomUUID(),
    name: 'Name',
    email: '[email protected]',
  };

  beforeEach(() => {
    authInterceptor.clearHandlers();
  });

  afterAll(() => {
    authInterceptor.expectNoUnusedHandlers();
  });

  describe('User creation', () => {
    const creationPayload: UserCreationPayload = {
      name: user.name,
      email: user.email,
      password: 'password',
    };

    async function createUser(payload: UserCreationPayload) {
      return await fetch('https://localhost:3000/users', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(payload),
      });
    }

    it('should support creating users - alternative 1', async () => {
      const creationTracker = authInterceptor
        .post('/users')
        .with({
          body: creationPayload,
        })
        .respond({
          status: 201,
          body: user,
        });

      // Code to test
      const response = await createUser(creationPayload);

      expect(response.status).toBe(201);

      const createdUser = (await response.json()) as User;
      expect(createdUser).toEqual(user);

      const creationRequests = creationTracker.requests();
      expect(creationRequests).toHaveLength(1);

      // Simpler alternative:
      // expect(creationRequests[0].body).toEqual(creationPayload);

      expect(creationRequests[0].body.name).toBe(creationPayload.name);
      expect(creationRequests[0].body.email).toBe(creationPayload.email);
      expect(creationRequests[0].body.password).toBe(creationPayload.password);
    });

    it('should support creating users - alternative 2', async () => {
      const creationTracker = authInterceptor.post('/users').respond({
        status: 201,
        body: user,
      });

      // Code to test
      const response = await createUser(creationPayload);

      expect(response.status).toBe(201);

      const createdUser = (await response.json()) as User;
      expect(createdUser).toEqual(user);

      const creationRequests = creationTracker.requests();
      expect(creationRequests).toHaveLength(1);

      // Simpler alternative:
      // expect(creationRequests[0].body).toEqual(creationPayload);

      expect(creationRequests[0].body.name).toBe(creationPayload.name);
      expect(creationRequests[0].body.email).toBe(creationPayload.email);
      expect(creationRequests[0].body.password).toBe(creationPayload.password);
    });

    it('should return an error if the payload is not valid', async () => {
      // @ts-expect-error Forcing an invalid payload
      const invalidPayload: UserCreationPayload = {};

      const creationTracker = authInterceptor
        .post('/users')
        .with({
          body: invalidPayload,
        })
        .respond({
          status: 400,
          body: {
            code: 'validation_error',
            message: 'Invalid payload',
          },
        });

      // Code to test
      const response = await createUser(invalidPayload);

      expect(response.status).toBe(400);

      const creationRequests = creationTracker.requests();
      expect(creationRequests).toHaveLength(1);

      expect(creationRequests[0].body).toEqual(invalidPayload);
    });

    it('should return an error if the payload is not valid', async () => {
      const conflictingPayload: UserCreationPayload = creationPayload;

      const creationTracker = authInterceptor
        .post('/users')
        .with({
          body: conflictingPayload,
        })
        .respond({
          status: 409,
          body: {
            code: 'conflict',
            message: 'User already exists',
          },
        });

      // Code to test
      const response = await createUser(conflictingPayload);

      expect(response.status).toBe(409);

      const creationRequests = creationTracker.requests();
      expect(creationRequests).toHaveLength(1);

      expect(creationRequests[0].body).toEqual(conflictingPayload);
    });
  });

  describe('User list', () => {
    async function listUsers(filters: { name?: string; email?: string } = {}) {
      const searchParams = new URLSearchParams(filters);
      return await fetch(`https://localhost:3000/users?${searchParams}`, { method: 'GET' });
    }

    it('should list users filtered by name', async () => {
      const nameToFilter = 'nam';

      const listTracker = authInterceptor
        .get('/users')
        .with({
          searchParams: { name: nameToFilter },
        })
        .respond({
          status: 200,
          body: [user],
        });

      // Code to test
      const response = await listUsers({ name: nameToFilter });

      expect(response.status).toBe(200);

      const returnedUsers = (await response.json()) as User[];
      expect(returnedUsers).toEqual([user]);

      const listRequests = listTracker.requests();
      expect(listRequests).toHaveLength(1);

      // Simpler alternative:
      // expect(listInterceptions[0].searchParams).toEqual(
      //   new URLSearchParams({
      //     name: nameToFilter,
      //   }),
      // );

      expect(listRequests[0].searchParams.size).toBe(1);
      expect(listRequests[0].searchParams.get('name')).toBe(nameToFilter);
    });
  });

  describe('User get by id', () => {
    async function getUserById(userId: string) {
      return await fetch(`https://localhost:3000/users/${userId}`, { method: 'GET' });
    }

    it('should support getting users by id - alternative 1', async () => {
      const getTracker = authInterceptor
        .get('/users/:id')
        .with({ routeParams: { id: user.id } })
        .respond({
          status: 200,
          body: user,
        });

      // Code to test
      const response = await getUserById(user.id);

      expect(response.status).toBe(200);

      const returnedUsers = (await response.json()) as User[];
      expect(returnedUsers).toEqual([user]);

      const getRequests = getTracker.requests();
      expect(getRequests).toHaveLength(1);

      // Simpler alternative:
      // expect(getRequests[0].routeParams).toEqual({ id: user.id });

      expect(getRequests[0].routeParams.id).toBe(user.id);
    });

    it('should support getting users by id - alternative 2', async () => {
      const getTracker = authInterceptor.get<'/users/:id'>(`/users/${user.id}`).respond({
        status: 200,
        body: user,
      });

      // Code to test
      const response = await getUserById(user.id);

      expect(response.status).toBe(200);

      const returnedUsers = (await response.json()) as User[];
      expect(returnedUsers).toEqual([user]);

      const getRequests = getTracker.requests();
      expect(getRequests).toHaveLength(1);

      // Simpler alternative:
      // expect(getRequests[0].routeParams).toEqual({});

      expect(getRequests[0].routeParams.id).toBe(undefined);
    });

    it('should return an error if the user was not found', async () => {
      const getTracker = authInterceptor
        .get('/users/:id')
        .with({
          routeParams: { id: user.id },
        })
        .respond({
          status: 404,
          body: {
            code: 'not_found',
            message: 'User not found',
          },
        });

      // Code to test
      const response = await getUserById(user.id);

      expect(response.status).toBe(404);

      const getRequests = getTracker.requests();
      expect(getRequests).toHaveLength(1);

      // Simpler alternative:
      // expect(getRequests[0].routeParams).toEqual({});

      expect(getRequests[0].routeParams.id).toBe(user.id);
    });
  });

  describe('User deletion', () => {
    async function deleteUserById(userId: string) {
      return await fetch(`https://localhost:3000/users/${userId}`, { method: 'DELETE' });
    }

    it('should support deleting users by id', async () => {
      const deleteTracker = authInterceptor
        .delete('/users/:id')
        .with({ routeParams: { id: user.id } })
        .respond({
          status: 204,
        });

      // Code to test
      const response = await deleteUserById(user.id);

      expect(response.status).toBe(204);

      const deleteRequests = deleteTracker.requests();
      expect(deleteRequests).toHaveLength(1);

      // Simpler alternative:
      // expect(deleteRequests[0].routeParams).toEqual({ id: user.id });

      expect(deleteRequests[0].routeParams.id).toBe(user.id);
    });

    it('should return an error if the user was not found', async () => {
      const getTracker = authInterceptor
        .delete('/users/:id')
        .with({
          routeParams: { id: user.id },
        })
        .respond({
          status: 404,
          body: {
            code: 'not_found',
            message: 'User not found',
          },
        });

      // Code to test
      const response = await deleteUserById(user.id);

      expect(response.status).toBe(404);

      const getRequests = getTracker.requests();
      expect(getRequests).toHaveLength(1);

      // Simpler alternative:
      // expect(getRequests[0].routeParams).toEqual({});

      expect(getRequests[0].routeParams.id).toBe(user.id);
    });
  });
});

Example 2

import * as crypto from 'crypto';
import { createHttpInterceptor } from './lib';
import { describe, beforeEach, it, expect, afterAll } from '../common/test';

interface User {
  id: string;
  name: string;
  email: string;
}

interface UserWithPassword extends User {
  password: string;
}

type UserCreationPayload = Omit<UserWithPassword, 'id'>;

interface RequestError {
  code: string;
  message: string;
}

interface ValidationError extends RequestError {
  code: 'validation_error';
}

interface ConflictError extends RequestError {
  code: 'conflict';
}

const authInterceptor = createHttpInterceptor<{
  '/users': {
    POST: {
      request: {
        body: UserCreationPayload;
      };
      response: {
        201: { body: User };
        400: { body: ValidationError };
        409: { body: ConflictError };
      };
    };
  };
}>({
  baseURL: 'https://localhost:3000',
  unhandledRequestStrategy: 'error',
  defaults: {
    '/users': {
      POST: {
        response: {
          status: 400,
          body: {
            code: 'validation_error',
            message: 'Invalid payload',
          },
        },
      },
    },
  },
});

const user: User = {
  id: crypto.randomUUID(),
  name: 'Name',
  email: '[email protected]',
};

const creationPayload: UserCreationPayload = {
  name: user.name,
  email: user.email,
  password: 'password',
};

async function createUser(payload: UserCreationPayload) {
  return await fetch('https://localhost:3000/users', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(payload),
  });
}

describe('Users', () => {
  beforeEach(() => {
    authInterceptor.clearHandlers();
  });

  afterAll(() => {
    authInterceptor.expectNoUnusedHandlers();
  });

  describe('User creation', () => {
    it('should support creating users', async () => {
      const creationTracker = authInterceptor
        .post('/users')
        .with({
          body: creationPayload,
        })
        .respond({
          status: 201,
          body: user,
        });

      // Code to test
      const response = await createUser(creationPayload);
      expect(response.status).toBe(201);

      const creationRequests = creationTracker.requests();
      expect(creationRequests).toHaveLength(1);

      expect(creationRequests[0].body.name).toBe(creationPayload.name);
      expect(creationRequests[0].body.email).toBe(creationPayload.email);
      expect(creationRequests[0].body.password).toBe(creationPayload.password);
    });
  });
});

Example 3

import * as crypto from 'crypto';

import { createHttpInterceptor } from './lib';
import { describe, beforeEach, it, expect, afterAll } from '../common/test';

export interface UserCreationPayload {
  name: string;
  email: string;
  password: string;
}

export interface User {
  id: string;
  name: string;
  email: string;
}

export interface ValidationErrorResponse {
  code: 'validation_error';
  message: string;
}

export const authInterceptor = createHttpInterceptor<{
  '/users': {
    POST: {
      request: {
        body: UserCreationPayload;
      };
      response: {
        201: { body: User };
        400: { body: ValidationErrorResponse };
      };
    };
  };
}>({
  baseURL: 'http://localhost:3000',
  unhandledRequestStrategy: 'error',
  defaults: {
    '/users': {
      POST: {
        response: {
          status: 400,
          body: {
            code: 'validation_error',
            message: 'Validation error',
          },
        },
      },
    },
  },
});

async function createUser(payload: UserCreationPayload) {
  return fetch('https://localhost:3000/users', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(payload),
  });
}

describe('Users', () => {
  const user: User = {
    id: crypto.randomUUID(),
    name: 'Name',
    email: '[email protected]',
  };

  beforeEach(() => {
    authInterceptor.clearHandlers();
  });

  afterAll(() => {
    authInterceptor.expectNoUnusedHandlers();
  });

  describe('User creation', () => {
    it('should support creating users', async () => {
      const creationPayload: UserCreationPayload = {
        name: user.name,
        email: user.email,
        password: 'password',
      };

      const creationTracker = authInterceptor
        .post('/users')
        .with({
          body: creationPayload,
        })
        .respond({
          status: 201,
          body: user,
        });

      // Code to test
      const response = await createUser(creationPayload);
      expect(response.status).toBe(201);

      const creationRequests = creationTracker.requests();

      expect(creationRequests).toHaveLength(1);

      expect(creationRequests[0].body.name).toBe(creationPayload.name);
      expect(creationRequests[0].body.email).toBe(creationPayload.email);
      expect(creationRequests[0].body.password).toBe(creationPayload.password);
    });
  });
});

Example 4

import * as crypto from 'crypto';
import { createHttpInterceptor } from './lib';
import { describe, beforeEach, it, expect, afterAll } from '../common/test';

interface User {
  id: string;
  name: string;
  email: string;
}

interface UserWithPassword extends User {
  password: string;
}

type UserCreationPayload = Omit<UserWithPassword, 'id'>;

interface RequestError {
  code: string;
  message: string;
}

interface ValidationError extends RequestError {
  code: 'validation_error';
}

interface ConflictError extends RequestError {
  code: 'conflict';
}

const authInterceptor = createHttpInterceptor<{
  '/users': {
    POST: {
      request: {
        formData: UserCreationPayload;
      };
      response: {
        201: { body: User };
        400: { body: ValidationError };
        409: { body: ConflictError };
      };
    };
  };
}>({
  baseURL: 'https://localhost:3000',
  unhandledRequestStrategy: 'error',
});

const user: User = {
  id: crypto.randomUUID(),
  name: 'Name',
  email: '[email protected]',
};

const creationPayload: UserCreationPayload = {
  name: user.name,
  email: user.email,
  password: 'password',
};

async function createUser(payload: UserCreationPayload) {
  return await fetch('https://localhost:3000/users', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(payload),
  });
}

describe('Users', () => {
  beforeEach(() => {
    authInterceptor.clearHandlers();
  });

  afterAll(() => {
    authInterceptor.expectNoUnusedHandlers();
  });

  describe('User creation', () => {
    it('should support creating users', async () => {
      const creationTracker = authInterceptor
        .post('/users')
        .with({
          formData: creationPayload,
        })
        .respond({
          status: 201,
          body: user,
        });

      // Code to test
      const response = await createUser(creationPayload);
      expect(response.status).toBe(201);

      const creationRequests = creationTracker.requests();
      expect(creationRequests).toHaveLength(1);

      expect(creationRequests[0].formData.get('name')).toBe(creationPayload.name);
      expect(creationRequests[0].formData.get('email')).toBe(creationPayload.email);
      expect(creationRequests[0].formData.get('password')).toBe(creationPayload.password);
    });
  });
});

Service schema typegen ideas

Definitions

  • Mocked service: application to which Zimic mocks are targeted
  • Client application: application that consumes the mocked service and where Zimic mocks are applied

Initial ideas

Alternative 1: Types inferred from source code

Assessment: promising, not feasible directly.

A utility package could be installed in a mocked service, allowing automatic schema type generation by a CLI. The generated types are exported to an intermediate package that must be imported by client apps.

  • Workflow:

    1. [mocked service] Install the utility package.
    2. [mocked service] Use helpers to "mark" endpoints and extract type schema? Infer type schema from framework utilities?
    3. Idea 1: declare the schema manually and provide utilities to validate if the endpoints are following it (promising)
    4. Idea 2: derive the schema from an OpenAPI specification and provide utilities to validate if the endpoints are following it
    5. [mocked service] Generate type schema of all of the endpoints using a CLI.
    6. [intermediate package] Commit and upload the generated types to a shared package.
    7. [client app] Install the intermediate package and import the generated types when creating interceptors.
  • Pros:

    • Type schema is derived directly from the source code in the mocked service.
    • A static type check could be integrated to CI environments to verify if the mocks are using valid types.
  • Cons:

    • Dependent of which framework or code patterns where used in the mocked service.
      • Adapters might be necessary.
      • Code markers might be necessary.
    • Not easily portable to mocked services using programming languages different from TypeScript.
    • Requires an intermediate types package.
    • Does not work if the client app developer does not have access to the source code of the mocked service (e.g. GitHub API could not be used)

Alternative 2: Types inferred from real requests

Assessment: not promising, requires manual intervention and is error prone.

A utility package could be installed in client apps, allowing automatic schema type generation by inferring the types from real requests.

  • Workflow:

    1. [tested service] Install the utility package.
    2. [tested service] The client app makes requests to the mocked service, without any mocks applied. The requests reach the real service,
    3. [tested service] The utility package tracks the sent requests and generates the type schema.
    4. [tested service] The generated schema can be used when creating interceptors.
  • Pros:

    • Type schema is derived directly from the behavior of the mocked service.
    • Independent of frameworks used in the mocked service.
    • Independent of programming languages used in the mocked service.
    • Does not use intermediate packages.
    • Does not require changes to the source code of the mocked service (e.g. GitHub API could be used).
  • Cons:

    • Relies that the client app developer runs the program reaching the real mocked service at least once for each possible request and response types.
    • Type schema cannot be easily generated and validated as part of a CI workflow.

Alternative 3: Types derived from endpoint specification

Assessment: most promising, will be implemented into Zimic.

Having access to the mocked service endpoint specification, Zimic could automatically generate the type schemas. The endpoint specification could follow the standard OpenAPI.

  • Workflow:

    1. [mocked service] Provide a path to get the OpenAPI specification of the service endpoints.
    2. [tested service] Fetch the OpenAPI specification and generate the type schema, by CLI or script.
    3. [tested service] The generated schema can be used when creating interceptors.
  • Pros:

    • Type schema is derived directly from the expected behavior of the mocked service.
    • A static type check could be integrated to CI environments to verify if the mocks are using valid types.
    • Independent of frameworks used in the mocked service.
    • Independent of programming languages used in the mocked service.
    • Does not use intermediate packages.
    • Does not require changes to the source code of the mocked service (e.g. GitHub API could be used).
    • There are already utility types that convert OpenAPI to TypeScript types (see https://github.com/drwpow/openapi-typescript and https://github.com/anymaniax/orval).
  • Cons:

    • Requires the mocked service to provide an OpenAPi specification.
    • Although OpenAPI is comprehensive, there are no guarantees that the code of the mocked service is following the specification, as it is not statically enforced. However, other programming languages do not have the TypeScript's rich ability to derive types, so this might not be a big problem.
    • HTTP-only if OpenAPI is used.

Extra: OpenAPI auto-generation

There could be ways to infer the OpenAPI specification from the source code of the mocked service. By doing this, alternatives 1 and 3 are combined:

  • If the mocked service already has an OpenAPI specification, it could be used by client apps to generate the type schema for mocks.
  • If the mocked service does not have an OpenAPI specification, it could be generated from the source code and provided in one of the endpoints or file, which could be accessed by client apps to generate the type schema for mocks.

See:

CI action setup

  • Check style
  • Lint code
  • Check types
  • Run tests
    • Unit and integration tests in each package
    • Tests in example packages, checking the API and previous behavior

HTTP interceptor request match by search params

  • The HTTP request tracker should support:
    • Defining search params to match requests
    • Receiving an optional argument to whether the match should be exact or not (default: false)
      const listTracker = authInterceptor
        .get('/users')
        .with({
          searchParams: { name: nameToFilter },
          exact: true
        })
        .respond({
          status: 200,
          body: [user],
        });

HTTP interceptor request match by headers

  • The HTTP request tracker should support:
    • Defining headers to match requests
    • Receiving a second, optional argument to whether the match should be exact or not (default: false)
      const listTracker = authInterceptor
        .get('/users')
        .with({
          headers: { 'content-type': 'application/json' },
          exact: true,
        })
        .respond({
          status: 200,
          body: [user],
        });

Access to the intercepted requests

  • The HTTP request tracker should support:
    • Accessing the data of the intercepted requests, to allow assertions in client code
  • This implementation should include tests in an example client, using all the implemented features and APIs. This is important to feel how intuitive the API is and ensure that current behaviors are not broken in the future.

Project setup

  • Turborepo
  • Lintstaged & config package
  • ESLint & config package
  • Prettier
  • TypeScript & config package
  • Git hooks (pre-commit and pre-push)
  • Commitlint
  • zimic main package
  • Empty HTTP interceptor exported (stub release to NPM)

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.