npm package: @sourceallies/apollo-schema-mocks
Description: Schema-aware GraphQL mocking utilities for Apollo Client + Vitest testing
Author: Source Allies
Latest version: 1.2.1

# @sourceallies/apollo-schema-mocks

Schema-aware GraphQL mocking utilities for Apollo Client + Vitest testing.

## Features

- **Schema-based mocking** - Uses your actual GraphQL schema for type-safe mocks
- **Spy/mock separation** - Track calls separately from return values (prevents nested mock issues)
- **Sequential responses** - `mockReturnValueOnce()` support for refetch/pagination testing
- **Scalar success values** - `scalarSuccess()` for queries that return scalar types (String!, ID!, etc.)
- **Conditional mocks** - `createConditionalResolver()` for variable-dependent behavior
- **Flexible schema input** - Accepts IntrospectionQuery, SDL string, or GraphQLSchema
- **Schema caching** - Improves test performance with WeakMap-based caching
- **Error mock helpers** - Pre-built helpers for common error scenarios
- **Framework-agnostic** - Works with React, Vue, Angular, Svelte, or any Apollo Client usage

## Installation

```bash
npm install --save-dev @sourceallies/apollo-schema-mocks
```

### Peer Dependencies

- `@apollo/client` ^3.0.0
- `graphql` ^16.0.0
- `vitest` >=1.0.0 (optional, required for spy mocks)

## Quick Start

### Basic Usage

```typescript
import { createMockApolloClientWithSchema } from '@sourceallies/apollo-schema-mocks';
import introspectionResult from './schema.json';

// Create a mock Apollo Client
const { client, schema } = createMockApolloClientWithSchema({
  schema: introspectionResult,
  queryMocks: {
    getUser: () => ({ id: '1', name: 'Alice', email: 'alice@example.com' }),
    getUsers: () => [{ id: '1', name: 'Alice' }, { id: '2', name: 'Bob' }],
  },
  mutationMocks: {
    createUser: () => ({ id: 'new-1', name: 'NewUser' }),
  },
});

// Use the client in your tests
const result = await client.query({ query: GET_USER, variables: { id: '1' } });
```

### Using Spy Mocks for Assertions

Spy mocks let you verify how operations were called while returning mock data:

```typescript
import {
  createMockApolloClientWithSchema,
  createQuerySpyMocks,
  createMutationSpyMocks,
  combineFieldMocks,
} from '@sourceallies/apollo-schema-mocks';

// Create spy mocks - spies are separate from mocks
const { spies: querySpies, mocks: queryMocks } = createQuerySpyMocks({
  getUser: { id: '1', name: 'Alice' },
  getUsers: [{ id: '1', name: 'Alice' }, { id: '2', name: 'Bob' }],
});

const { spies: mutationSpies, mocks: mutationMocks } = createMutationSpyMocks({
  createUser: { id: 'new-1', name: 'NewUser' },
});

// Combine query and mutation mocks
const combined = combineFieldMocks(
  queryMocks,
  mutationMocks,
);

// Create the mock client
const { client } = createMockApolloClientWithSchema({
  schema: introspectionResult,
  queryMocks: combined.Query,
  mutationMocks: combined.Mutation,
});

// Run your test
await client.query({ query: GET_USER, variables: { id: '1' } });
await client.mutate({ mutation: CREATE_USER, variables: { input: { name: 'Alice' } } });

// Assert on spies
expect(querySpies.getUser).toHaveBeenCalledWith({ id: '1' });
expect(mutationSpies.createUser).toHaveBeenCalledWith({ input: { name: 'Alice' } });
```

### Sequential Responses with mockReturnValueOnce

Spy mocks support `mockReturnValueOnce()` for testing refetch, pagination, and optimistic update scenarios:

```typescript
const { spies, mocks, mockData } = createQuerySpyMocks({
  getOrders: { orders: [order1, order2], totalCount: 2 },
});

// Override the first call to return different data
spies.getOrders.mockReturnValueOnce({ orders: [order1], totalCount: 1 });

// First call returns the override (1 order)
// Subsequent calls return the default mockData (2 orders)
```

### Scalar Success Values

When mocking queries that return scalar types (String!, ID!, Int!), use `scalarSuccess()` to distinguish from error strings:

```typescript
import { createQuerySpyMocks, scalarSuccess } from '@sourceallies/apollo-schema-mocks';

const { mocks } = createQuerySpyMocks({
  getEmailBody: scalarSuccess('Hello, this is the email body'),
  getUserCount: scalarSuccess(42),
  isFeatureEnabled: scalarSuccess(true),
});
```

Without `scalarSuccess()`, a plain string like `'Hello'` would be treated as an error message.

### Conditional Mocks

For mocks that need to return different results based on variables at call time:

```typescript
import { createConditionalResolver } from '@sourceallies/apollo-schema-mocks';

// Use directly as a query/mutation mock
const { client } = createMockApolloClientWithSchema({
  schema: introspectionResult,
  queryMocks: {
    getUser: createConditionalResolver(
      (vars) => (vars as { id: string }).id === '123',
      { id: '123', name: 'Alice' },
      'User not found',
    ),
  },
});
```

### Error Mocks

Test error handling with pre-built error helpers:

```typescript
import {
  createQuerySpyMocks,
  createErrorMock,
  createAuthenticationError,
  createAuthorizationError,
  createNotFoundError,
  createValidationError,
} from '@sourceallies/apollo-schema-mocks';

const { mocks } = createQuerySpyMocks({
  // Custom error
  getUser: createErrorMock('User not found', { extensions: { code: 'NOT_FOUND' } }),

  // Pre-built error helpers
  getSecretData: createAuthenticationError(),
  getAdminData: createAuthorizationError(),
  getProfile: createNotFoundError('Profile', '123'),
});

const { mocks: mutationMocks } = createMutationSpyMocks({
  createUser: createValidationError('Email is required', ['input', 'email']),
});

// Simple string errors
const { mocks: errorMocks } = createQuerySpyMocks({
  getUser: 'User not found',  // Throws GraphQLError with this message
});
```

Available error helpers:
- `createErrorMock(message, options)` - Custom error with extensions
- `createMultipleErrorsMock(errors)` - Multiple errors in one response
- `createAuthenticationError(message?)` - UNAUTHENTICATED error
- `createAuthorizationError(message?)` - FORBIDDEN error
- `createValidationError(message, field?)` - VALIDATION_ERROR error
- `createNotFoundError(resource, id?)` - NOT_FOUND error
- `createInternalError(message?)` - INTERNAL_SERVER_ERROR error
- `createNetworkError(message?)` - NETWORK_ERROR error
- `conditionalErrorMock(condition, error, data)` - Return error or data based on condition
- `createConditionalResolver(condition, data, error)` - Variable-dependent success/error at call time

### Schema Caching

The library automatically caches built schemas for performance:

```typescript
import { schemaCacheStats, clearSchemaCache } from '@sourceallies/apollo-schema-mocks';

// Check cache performance
console.log(schemaCacheStats.hitRate); // e.g., 0.95
console.log(schemaCacheStats.hits);    // e.g., 19
console.log(schemaCacheStats.misses);  // e.g., 1

// Reset stats between test suites if needed
schemaCacheStats.reset();

// Clear cache if needed (rarely necessary)
clearSchemaCache();
```

### Custom Scalar Mocks

Override the default scalar mocks or add your own:

```typescript
const { client } = createMockApolloClientWithSchema({
  schema: introspectionResult,
  scalars: {
    Date: () => '2024-01-15',
    DateTime: () => '2024-01-15T10:30:00Z',
    JSON: () => ({ custom: 'data' }),
    CustomScalar: () => 'custom-value',
  },
  queryMocks: {
    // ...
  },
});
```

Default scalar mocks:
- `String` - `"mock-string"`
- `Int` - `42`
- `Float` - `3.14`
- `Boolean` - `true`
- `ID` - `"mock-id"`
- `Date` - `"2024-01-01"`
- `DateTime` - `"2024-01-01T00:00:00Z"`
- `JSON` - Empty object
- `BigInt` - Max safe integer as string

## API Reference

### Core

| Function | Description |
|----------|-------------|
| `createMockApolloClientWithSchema(options)` | Creates a mock Apollo Client with schema-based mocks |
| `getOrBuildSchema(input)` | Builds or retrieves a cached GraphQL schema |
| `clearSchemaCache()` | Clears the schema cache |
| `schemaCacheStats` | Object with cache hit/miss statistics |

### Helpers

| Function | Description |
|----------|-------------|
| `createQuerySpyMocks(mocks)` | Creates query mocks with spies for assertions |
| `createQueryMocks(mocks)` | Creates simple query mocks without spies |
| `createQueryMock(name, value)` | Creates a single query mock |
| `createMutationSpyMocks(mocks)` | Creates mutation mocks with spies |
| `createMutationMocks(mocks)` | Creates simple mutation mocks |
| `createMutationMock(name, value)` | Creates a single mutation mock |
| `createSubscriptionSpyMocks(mocks)` | Creates subscription mocks with spies |
| `createSubscriptionMocks(mocks)` | Creates simple subscription mocks |
| `createSubscriptionMock(name, value)` | Creates a single subscription mock |
| `combineFieldMocks(...results)` | Combines multiple mock results |
| `scalarSuccess(value)` | Wraps a scalar value for successful mock returns |
| `isScalarSuccess(value)` | Type guard for scalar success values |
| `isErrorMock(value)` | Type guard for error mocks |
| `createConditionalResolver(condition, data, error)` | Variable-dependent mock |

## Schema Input Formats

The library accepts schemas in multiple formats:

```typescript
// 1. IntrospectionQuery (from graphql-codegen or similar)
import introspection from './schema.json';
createMockApolloClientWithSchema({ schema: introspection, ... });

// 2. SDL string
const sdl = `
  type Query {
    user(id: ID!): User
  }
  type User {
    id: ID!
    name: String!
  }
`;
createMockApolloClientWithSchema({ schema: sdl, ... });

// 3. GraphQLSchema object
import { buildSchema } from 'graphql';
const schema = buildSchema(sdl);
createMockApolloClientWithSchema({ schema, ... });
```

## Why Spy/Mock Separation?

Traditional mocking approaches often use `vi.fn().mockReturnValue()` directly as resolvers. This causes issues with nested mock values because Vitest's mock functions have additional properties that interfere with GraphQL field resolution.

This library separates concerns:
- **Spies** are Vitest mock functions for tracking calls, with `mockImplementation` returning the mock data
- **Mocks** are plain functions that delegate to the spy

```typescript
// WRONG - causes nested mock value issues
const userMock = vi.fn().mockReturnValue({ id: '1', name: 'Alice' });
// userMock has extra properties that confuse GraphQL

// CORRECT - this library's approach
const { spies, mocks } = createQuerySpyMocks({
  user: { id: '1', name: 'Alice' },
});
// spies.user is for assertions (default implementation returns mock data)
// mocks.Query.user delegates to the spy and returns clean data
// spies.user.mockReturnValueOnce() can override responses for one call
```

## Documentation

- [API Reference](./docs/API_REFERENCE.md)
- [Usage Examples](./docs/USAGE_EXAMPLES.md)
- [Migration Guide](./docs/MIGRATION_GUIDE.md)

## Requirements

- Node.js >= 18
- Apollo Client >= 3.0.0
- GraphQL >= 16.0.0

## License

MIT - See [LICENSE](./LICENSE) for details.