Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds DevMock Service #7

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions src/dev-mock-service/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
## Dev Mock Service
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔕 suggest consider renaming to just "mocks". "dev" and "service" aren't buying me much. same thing with the subdirectory names which could be like mock-reddit instead of reddit-api-mock-service.


Dev Mock Service is an API mocking library that allows you to specify
custom responses for any API calls inside your app.

### Capabilities

- Mock API requests
- Dev/Prod switch

### Installation

Add the line `import { DevMockMode, DevMock } from '@devvit/kit';` in the beginning of your root component.

### API mocks

With custom handler functions, you can override the response for any API call, such as
Redis, RedditAPI, or HTTP request.

- In Dev mode (`DevMockMode.Dev`), handlers are applied for all requests with the matching method and ID (if available).
- In Prod mode (`DevMockMode.Prod`), handlers are ignored.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this will encourage users to ship development-only code which may be large. I'd love to see this code fall out of apps for at least non-prerelease builds so there wasn't a wrong way to use it.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How can I achieve this?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd like the introduction of a IS_PROD or NODE_ENV or IS_PLAYTEST environmental variable that could be statically added at build time for tree shaking. Would be useful for more than just here

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we can implement the IS_PLAYTEST and tree shaking separately, so it does not block this PR


#### Setup

Create dev versions of the API clients you want to mock.

```typescript
const { devRedis, devRedditApi, devFetch } = DevMock.createService({
context,
mode: DevMockMode.Dev,
handlers: [
DevMock.redis.get("mocked_key", () => "Value from mocks!"),
DevMock.fetch.get("https://example.com", () =>
DevMock.httpResponse.ok({ fetched: "mock" }),
),
DevMock.reddit.getSubredditById((id: string) => ({ name: `mock_${id}` })),
],
});
```

Use dev versions of API clients in your app.

```typescript
const redisValue = await devRedis.get("mocked_key"); // "Value from mocks!"
const fetchedValue = await(await devFetch("https://example.com")).json(); // {fetched: "mock"}
const redditApiValue = (await devRedditApi.getSubredditById("t5_123")).name; // "mock_t5_123"
```
189 changes: 189 additions & 0 deletions src/dev-mock-service/dev-mock-service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import { DevMock, DevMockMode } from "./dev-mock-service.js";
import type { Devvit } from "@devvit/public-api";
import type { Mock } from "vitest";
import type { Subreddit } from "@devvit/public-api";

describe("dev mock service", () => {
const mockContext: Devvit.Context = {
useState: vi.fn(),
redis: {
get: vi.fn(),
},
reddit: {
getSubredditById: vi.fn(),
getCurrentUser: vi.fn(),
},
} as unknown as Devvit.Context;

afterEach(() => {
(mockContext.useState as Mock).mockReset();
(mockContext.redis.get as Mock).mockReset();
(mockContext.reddit.getSubredditById as Mock).mockReset();
(mockContext.reddit.getCurrentUser as Mock).mockReset();
});

describe("init api", () => {
describe("redis", () => {
it("returns devRedis", () => {
const devMockService = DevMock.createService({
context: mockContext,
mode: DevMockMode.Prod,
});
expect(devMockService.devRedis).toBeDefined();
});

it("returns devRedis that calls the original method if no handlers are provided", async () => {
(mockContext.redis.get as Mock).mockResolvedValue("real redis");
const devMockService = DevMock.createService({
context: mockContext,
mode: DevMockMode.Dev,
});
const response = await devMockService.devRedis.get("regular_key");
expect(mockContext.redis.get).toBeCalledWith("regular_key");
expect(mockContext.redis.get).toHaveBeenCalledOnce();
expect(response).toBe("real redis");
});

it("returns devRedis that applies mock responses", async () => {
(mockContext.redis.get as Mock).mockResolvedValue("real redis");
const devMockService = DevMock.createService({
context: mockContext,
mode: DevMockMode.Dev,
handlers: [DevMock.redis.get("mocked_key", () => "mocked_response")],
});
const response = await devMockService.devRedis.get("mocked_key");
expect(mockContext.redis.get).not.toBeCalled();
expect(response).toBe("mocked_response");
});

it("ignores mocks in prod mode", async () => {
(mockContext.redis.get as Mock).mockResolvedValue("real redis");
const devMockService = DevMock.createService({
context: mockContext,
mode: DevMockMode.Prod,
handlers: [DevMock.redis.get("mocked_key", () => "mocked_response")],
});
const response = await devMockService.devRedis.get("mocked_key");
expect(mockContext.redis.get).toBeCalledWith("mocked_key");
expect(mockContext.redis.get).toHaveBeenCalledOnce();
expect(response).toBe("real redis");
});
});
describe("redditApi", () => {
it("returns devRedditApi", () => {
const devMockService = DevMock.createService({
context: mockContext,
mode: DevMockMode.Prod,
});
expect(devMockService.devRedditApi).toBeDefined();
});

it("calls the original method if no handlers are provided", async () => {
(mockContext.reddit.getCurrentUser as Mock).mockResolvedValue({
name: "real_user",
});
const devMockService = DevMock.createService({
context: mockContext,
mode: DevMockMode.Dev,
});
const response = await devMockService.devRedditApi.getCurrentUser();
expect(mockContext.reddit.getCurrentUser).toHaveBeenCalledOnce();
expect(response).toStrictEqual({ name: "real_user" });
});

it("calls the mock handler if provided", async () => {
(mockContext.reddit.getSubredditById as Mock).mockResolvedValue({
name: "realSubreddit",
});
const devMockService = DevMock.createService({
context: mockContext,
mode: DevMockMode.Dev,
handlers: [
DevMock.reddit.getSubredditById(
(id: string) => ({ name: `mock_${id}` }) as Subreddit,
),
],
});
const response =
await devMockService.devRedditApi.getSubredditById("test");
expect(mockContext.reddit.getSubredditById).not.toBeCalled();
expect(response).toStrictEqual({ name: "mock_test" });
});

it("ignores mocks in prod mode", async () => {
(mockContext.reddit.getSubredditById as Mock).mockResolvedValue({
name: "realSubreddit",
});
const devMockService = DevMock.createService({
context: mockContext,
mode: DevMockMode.Prod,
handlers: [
DevMock.reddit.getSubredditById(
(id: string) => ({ name: `mock_${id}` }) as Subreddit,
),
],
});
const response =
await devMockService.devRedditApi.getSubredditById("test");
expect(mockContext.reddit.getSubredditById).toBeCalled();
expect(response).toStrictEqual({ name: "realSubreddit" });
});
});

describe("httpApi", () => {
const mockFetch = vi.fn();
const originalFetch = global.fetch;

beforeEach(() => {
mockFetch.mockReset();
global.fetch = mockFetch;
});

afterEach(() => {
global.fetch = originalFetch;
});

it("returns devFetch", () => {
const devMockService = DevMock.createService({
context: mockContext,
mode: DevMockMode.Prod,
});
expect(devMockService.devFetch).toBeDefined();
});

it("calls the original method if no handlers are provided", async () => {
mockFetch.mockResolvedValue({
json: () => Promise.resolve({ real: "data" }),
});
const devMockService = DevMock.createService({
context: mockContext,
mode: DevMockMode.Dev,
});
const response = await devMockService.devFetch(
"https://example.com",
{},
);
expect(mockFetch).toHaveBeenCalledOnce();
expect(mockFetch).toBeCalledWith("https://example.com", {});
expect(await response.json()).toStrictEqual({ real: "data" });
});

it("uses handler if provided", async () => {
const devMockService = DevMock.createService({
context: mockContext,
mode: DevMockMode.Dev,
handlers: [
DevMock.fetch.get("https://example.com", () => {
return DevMock.httpResponse.ok({ mocked: "response" });
}),
],
});
const response = await devMockService.devFetch("https://example.com", {
method: "GET",
});
expect(mockFetch).not.toBeCalled();
expect(await response.json()).toStrictEqual({ mocked: "response" });
});
});
});
});
66 changes: 66 additions & 0 deletions src/dev-mock-service/dev-mock-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type { RedditAPIClient, RedisClient } from "@devvit/public-api";
import type { Devvit } from "@devvit/public-api";
import type { HandlerOverride } from "./types/index.js";

import {
createdevRedis,
isRedisOverride,
redisHandler,
} from "./redis-mock-service/index.js";
import {
createdevRedditApi,
isRedditApiOverride,
redditApiHandler,
} from "./reddit-api-mock-service/index.js";
import {
createdevFetch,
httpHandler,
httpResponse,
isHttpApiOverride,
} from "./http-mock-service/index.js";

export enum DevMockMode {
Prod = "Prod",
Dev = "Dev",
}

export type DevMockService = {
devRedis: RedisClient;
devRedditApi: RedditAPIClient;
devFetch: typeof fetch;
};

const createDevMockService = (config: {
context: Devvit.Context;
mode: DevMockMode;
handlers?: HandlerOverride[];
}): DevMockService => {
if (config.mode === DevMockMode.Prod) {
return {
devRedis: config.context.redis,
devRedditApi: config.context.reddit,
devFetch: fetch,
};
}
const redisHandlers = config.handlers?.filter(isRedisOverride) || [];
const devRedis = createdevRedis(config.context.redis, redisHandlers);

const redditApiHandlers = config.handlers?.filter(isRedditApiOverride) || [];
const devRedditApi = createdevRedditApi(
config.context.reddit,
redditApiHandlers,
);

const httpApiHandlers = config.handlers?.filter(isHttpApiOverride) || [];
const devFetch = createdevFetch(fetch, httpApiHandlers);

return { devRedis, devRedditApi, devFetch };
};

export const DevMock = {
createService: createDevMockService,
redis: redisHandler,
reddit: redditApiHandler,
fetch: httpHandler,
httpResponse: httpResponse,
} as const;
Loading