Skip to main content
Glama
API_MOCKING_GUIDE.md16.8 kB
# API Mocking Guide ## Overview This guide provides comprehensive instructions for mocking the Lokalise API in the lokalise-mcp project. Based on analysis of the official Lokalise SDK testing patterns, we use Vitest module mocking rather than HTTP-level mocking for better type safety and simpler test setup. ## Mocking Strategy ### Approach Comparison | Approach | Pros | Cons | When to Use | |----------|------|------|-------------| | **Vitest Module Mocking** (Recommended) | Type-safe, Simple setup, Fast execution | Not testing HTTP layer | Unit/Integration tests | | **HTTP Mocking (undici)** | Tests full stack, Realistic | Complex setup, Slower | E2E tests only | | **Service Mocking** | Focused testing, Fast | Limited scope | Controller tests | ### Our Strategy We mock at the **module level** using Vitest to mock the `@lokalise/node-api` package. This provides: - Full type safety with TypeScript - Simple, maintainable test code - Fast test execution - Easy error simulation ## Three-Tier Mocking Architecture Our testing infrastructure uses three distinct mocking approaches: ### 1. Module Mocks (`__mocks__/`) **Purpose**: Replace entire modules with mock implementations for unit testing **Location**: `src/domains/{domain}/__mocks__/{module}.js` **Usage**: Testing tools, resources, and CLI layers in isolation ### 2. Mock Builders (`mock-builders/`) **Purpose**: Create realistic test data with a fluent API **Location**: `src/test-utils/mock-builders/{domain}.mock.ts` **Usage**: Testing controllers and formatters with realistic data ### 3. Mock Factory (`mock-factory.ts`) **Purpose**: Create mock Lokalise API client for service layer testing **Location**: `src/test-utils/mock-factory.ts` **Usage**: Testing service layer with simulated API responses ## Mock Factory Architecture ### Core Mock Factory ```typescript // src/test-utils/mock-factory.ts import { vi } from "vitest"; import type { LokaliseApi } from "@lokalise/node-api"; import type { PaginatedResult, Project, Key, Language, Task, Comment, Translation, Contributor } from "@lokalise/node-api"; export interface MockLokaliseApiOptions { failOnMethod?: string; errorCode?: number; errorMessage?: string; delay?: number; } export function createMockLokaliseApi(options: MockLokaliseApiOptions = {}) { const mockApi = { projects: vi.fn(() => ({ list: vi.fn(), get: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn(), empty: vi.fn(), })), keys: vi.fn(() => ({ list: vi.fn(), get: vi.fn(), create: vi.fn(), update: vi.fn(), bulk_update: vi.fn(), delete: vi.fn(), bulk_delete: vi.fn(), })), languages: vi.fn(() => ({ system_languages: vi.fn(), list: vi.fn(), get: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn(), })), tasks: vi.fn(() => ({ list: vi.fn(), get: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn(), })), comments: vi.fn(() => ({ list_project_comments: vi.fn(), list_key_comments: vi.fn(), get: vi.fn(), create: vi.fn(), delete: vi.fn(), })), translations: vi.fn(() => ({ list: vi.fn(), get: vi.fn(), update: vi.fn(), })), contributors: vi.fn(() => ({ list: vi.fn(), get: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn(), })), }; // Apply error simulation if specified if (options.failOnMethod) { const [domain, method] = options.failOnMethod.split('.'); if (mockApi[domain]) { mockApi[domain]()[method] = vi.fn().mockRejectedValue( new Error(options.errorMessage || `API Error: ${options.errorCode || 500}`) ); } } // Apply delay if specified if (options.delay) { Object.keys(mockApi).forEach(domain => { const domainMethods = mockApi[domain](); Object.keys(domainMethods).forEach(method => { const original = domainMethods[method]; domainMethods[method] = vi.fn(async (...args) => { await new Promise(resolve => setTimeout(resolve, options.delay)); return original(...args); }); }); }); } return mockApi as unknown as LokaliseApi; } ``` ### Domain-Specific Mock Builders ```typescript // src/test-utils/mock-builders/projects.mock.ts import type { Project, PaginatedResult } from "@lokalise/node-api"; export class ProjectsMockBuilder { private projects: Project[] = []; private totalCount = 0; private page = 1; private limit = 100; withProject(project: Partial<Project>): this { this.projects.push({ project_id: project.project_id || "test_project_id", name: project.name || "Test Project", description: project.description || "", created_at: project.created_at || "2024-01-01 00:00:00 (Etc/UTC)", created_at_timestamp: project.created_at_timestamp || 1704067200, created_by: project.created_by || 12345, created_by_email: project.created_by_email || "test@example.com", team_id: project.team_id || 100, base_language_id: project.base_language_id || 640, base_language_iso: project.base_language_iso || "en", project_type: project.project_type || "localization_files", settings: project.settings || {}, statistics: project.statistics || { progress_total: 0, keys_total: 0, team: 0, base_words: 0, qa_issues_total: 0, qa_issues: {}, languages: [] }, ...project }); this.totalCount++; return this; } withPagination(page: number, limit: number): this { this.page = page; this.limit = limit; return this; } build(): PaginatedResult<Project> { const totalPages = Math.ceil(this.totalCount / this.limit); return { items: this.projects, totalResults: this.totalCount, totalPages, resultsPerPage: this.limit, currentPage: this.page, hasNextPage: () => this.page < totalPages, hasPrevPage: () => this.page > 1, isFirstPage: () => this.page === 1, isLastPage: () => this.page === totalPages, nextPage: () => this.page + 1, prevPage: () => this.page - 1 }; } } ``` ## Module Mocking Patterns ### Module Mock Example ```javascript // src/domains/projects/__mocks__/projects.controller.js import { vi } from "vitest"; export default { listProjects: vi.fn(), getProjectDetails: vi.fn(), createProject: vi.fn(), updateProject: vi.fn(), deleteProject: vi.fn(), emptyProject: vi.fn(), }; ``` ### Using Module Mocks in Tests ```typescript // src/domains/projects/projects.tool.test.ts import { vi, describe, it, expect, beforeEach } from "vitest"; // This loads __mocks__/projects.controller.js automatically vi.mock("./projects.controller.js"); import projectsController from "./projects.controller.js"; import projectsTool from "./projects.tool.js"; describe("ProjectsTool", () => { const mockedController = vi.mocked(projectsController); beforeEach(() => { vi.clearAllMocks(); }); it("should call controller with correct arguments", async () => { const mockResponse = { content: "Projects list", data: [] }; mockedController.listProjects.mockResolvedValue(mockResponse); // Test implementation... }); }); ``` ## Error Simulation Patterns ```typescript // src/test-utils/error-simulator.ts export class ApiErrorSimulator { static unauthorized() { const error = new Error("Unauthorized"); (error as any).response = { status: 401, data: { error: { message: "Invalid API token", code: 401 } } }; return error; } static forbidden() { const error = new Error("Forbidden"); (error as any).response = { status: 403, data: { error: { message: "Permission denied", code: 403 } } }; return error; } static notFound() { const error = new Error("Not Found"); (error as any).response = { status: 404, data: { error: { message: "Resource not found", code: 404 } } }; return error; } static rateLimited() { const error = new Error("Too Many Requests"); (error as any).response = { status: 429, data: { error: { message: "Rate limit exceeded", code: 429 } }, headers: { "x-rate-limit-limit": "6000", "x-rate-limit-remaining": "0", "x-rate-limit-reset": String(Date.now() / 1000 + 60) } }; return error; } static serverError() { const error = new Error("Internal Server Error"); (error as any).response = { status: 500, data: { error: { message: "An unexpected error occurred" } } }; return error; } } ``` ## Rate Limiting Simulation ```typescript // src/test-utils/rate-limiter.mock.ts import { vi } from "vitest"; export class RateLimiterMock { private requestCount = 0; private resetTime = Date.now() + 3600000; // 1 hour from now private limit = 6000; async simulateRequest<T>( mockFn: ReturnType<typeof vi.fn>, response: T ): Promise<T> { this.requestCount++; if (this.requestCount > this.limit) { throw ApiErrorSimulator.rateLimited(); } // Simulate API delay await new Promise(resolve => setTimeout(resolve, 10)); return response; } reset(): void { this.requestCount = 0; this.resetTime = Date.now() + 3600000; } getRemainingRequests(): number { return Math.max(0, this.limit - this.requestCount); } } ``` ## Authentication Mocking ```typescript // src/test-utils/auth.mock.ts import { vi } from "vitest"; export function mockAuthentication(isValid: boolean = true) { if (!isValid) { return vi.fn().mockRejectedValue(ApiErrorSimulator.unauthorized()); } return vi.fn().mockImplementation((config) => { // Verify API key is present if (!config.apiKey) { throw ApiErrorSimulator.unauthorized(); } return config; }); } ``` ## Complete Test Setup Example ```typescript // src/domains/projects/projects.service.test.ts import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { createMockLokaliseApi } from "../../test-utils/mock-factory"; import { ProjectsMockBuilder } from "../../test-utils/mock-builders/projects.mock"; import { ApiErrorSimulator } from "../../test-utils/error-simulator"; // Mock the service's getLokaliseApi function vi.mock("../../shared/utils/lokalise-api.util.js"); import projectsService from "./projects.service.js"; import { getLokaliseApi } from "../../shared/utils/lokalise-api.util.js"; describe("ProjectsService", () => { const mockGetLokaliseApi = vi.mocked(getLokaliseApi); let mockApi: ReturnType<typeof createMockLokaliseApi>; let mockProjects: any; beforeEach(() => { vi.clearAllMocks(); // Create mock API mockApi = createMockLokaliseApi(); mockProjects = mockApi.projects(); // Configure the mock to return our API mockGetLokaliseApi.mockReturnValue(mockApi as unknown as LokaliseApi); }); afterEach(() => { vi.clearAllMocks(); }); describe("getProjects", () => { it("should fetch projects with pagination", async () => { // Arrange const mockBuilder = new ProjectsMockBuilder(); const mockResponse = mockBuilder .withProject({ name: "Test Project", project_id: "123.45" }) .withProject({ name: "Another Project", project_id: "678.90" }) .withPagination(1, 100) .build(); mockProjects.list.mockResolvedValue(mockResponse); // Act const result = await projectsService.getProjects({ page: 1, limit: 100 }); // Assert expect(result).toEqual(mockResponse); expect(mockProjects.list).toHaveBeenCalledWith({ page: 1, limit: 100 }); }); it("should handle API errors", async () => { // Arrange mockProjects.list.mockRejectedValue(ApiErrorSimulator.serverError()); // Act & Assert await expect(projectsService.getProjects({})) .rejects .toThrow("Internal Server Error"); }); it("should handle rate limiting", async () => { // Arrange mockProjects.list.mockRejectedValue(ApiErrorSimulator.rateLimited()); // Act & Assert await expect(projectsService.getProjects({})) .rejects .toThrow("Too Many Requests"); }); }); }); ``` ## Pagination Mocking Helpers ```typescript // src/test-utils/pagination.helpers.ts export function mockPaginatedResponse<T>( items: T[], page = 1, limit = 100, total?: number ) { const totalResults = total || items.length; const totalPages = Math.ceil(totalResults / limit); return { items, totalResults, totalPages, resultsPerPage: limit, currentPage: page, hasNextPage: () => page < totalPages, hasPrevPage: () => page > 1, isFirstPage: () => page === 1, isLastPage: () => page === totalPages, nextPage: () => page + 1, prevPage: () => page - 1 }; } export function mockCursorPaginatedResponse<T>( items: T[], nextCursor: string | null = null, limit = 100 ) { return { items, totalResults: 0, // Not provided in cursor pagination totalPages: 0, resultsPerPage: limit, currentPage: 0, nextCursor, hasNextCursor: () => nextCursor !== null, hasNextPage: () => false, hasPrevPage: () => false, nextPage: () => 0, prevPage: () => 0 }; } ``` ## Bulk Operation Mocking ```typescript // src/test-utils/bulk-operations.helpers.ts export function mockBulkOperation<T>( successItems: T[], failedItems: Array<{ item: T; error: string }> ) { return { items: successItems, errors: failedItems.map(f => ({ ...f.item, error: { message: f.error } })) }; } // Usage mockApi.keys().bulk_update.mockResolvedValue( mockBulkOperation( [{ key_id: 1, key_name: "updated.key" }], [{ item: { key_id: 2 }, error: "Key is locked" }] ) ); ``` ## Integration with Vitest Configuration ```typescript // vitest.config.ts import { defineConfig } from "vitest/config"; export default defineConfig({ test: { globals: true, environment: "node", setupFiles: ["./src/test-utils/setup.ts"], clearMocks: true, restoreMocks: true, mockReset: true, }, }); ``` ## Best Practices ### 1. Always Reset Mocks ```typescript afterEach(() => { vi.clearAllMocks(); }); ``` ### 2. Use Type-Safe Builders Always use the mock builders to ensure type safety and realistic data. ### 3. Test Error Scenarios Every test suite should include error handling tests. ### 4. Mock at the Right Level - Service tests: Mock LokaliseApi with Mock Factory - Controller tests: Mock services with Module Mocks - Tool tests: Mock controllers with Module Mocks ### 5. Keep Fixtures Realistic Use actual API response structures from the SDK fixtures as reference. ## Common Patterns ### Testing with Different API Keys ```typescript it("should handle invalid API key", async () => { mockGetLokaliseApi.mockImplementation(() => { throw ApiErrorSimulator.unauthorized(); }); await expect(service.getProjects({})) .rejects .toThrow("Unauthorized"); }); ``` ### Testing with Network Delays ```typescript it("should handle network delays", async () => { const mockApi = createMockLokaliseApi({ delay: 1000 }); mockGetLokaliseApi.mockReturnValue(mockApi as unknown as LokaliseApi); const start = Date.now(); await service.getProjects({}); const duration = Date.now() - start; expect(duration).toBeGreaterThanOrEqual(1000); }); ``` ### Testing Concurrent Operations ```typescript it("should handle concurrent requests", async () => { const promises = Array.from({ length: 10 }, (_, i) => service.getProject(`project_${i}`) ); const results = await Promise.all(promises); expect(results).toHaveLength(10); expect(mockProjects.get).toHaveBeenCalledTimes(10); }); ``` ## Troubleshooting ### Common Issues 1. **Mock Not Working** - Check if using correct mock type for layer - Module mocks must be in `__mocks__/` directory - Verify vi.mock() placement (before imports) - Ensure `.js` extension in mock path 2. **Type Errors** - NEVER use `any` - it's forbidden - Use `unknown` as intermediate type - Create proper interfaces for mocks - Cast through `unknown`: `mockApi as unknown as LokaliseApi` 3. **Async Issues** - Always await async operations - Use mockResolvedValue for async success - Use mockRejectedValue for async errors --- **Document Version**: 2.0.0 **Last Updated**: 2025-08-26 **Framework**: Vitest **Related**: TESTING-GUIDE.md, NEW-DOMAIN-TESTING.md, TEST-TROUBLESHOOTING.md

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/AbdallahAHO/lokalise-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server