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 6 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 Worker is an API mocking library that allows you to specify
Copy link

Choose a reason for hiding this comment

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

This isn't actually a server worker is 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.

Whoops, bad copy paste 😅

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 devv versions of the API clients you want to mock.
Copy link

Choose a reason for hiding this comment

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

Is this in the test file or in the real app code?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Real app code, this molecule is not intended to be used in unit tests (however it certainly can be)


```typescript
const { devvRedis, devvRedditApi, devvFetch } = 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 devv versions of API clients in your app.

```typescript
const redisValue = await devvRedis.get("mocked_key"); // "Value from mocks!"
const fetchedValue = await(await devvFetch("https://example.com")).json(); // {fetched: "mock"}
Copy link

Choose a reason for hiding this comment

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

nit: double await sort of always been a weird pattern to me instead of an intermediate var. Especially to noobies

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This is just an example to demonstrate the responses
Do you think it's worth breaking into 3 sections? redis, reddit and fetch separately

const redditApiValue = (await devvRedditApi.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 devvRedis", () => {
const devMockService = DevMock.createService({
context: mockContext,
mode: DevMockMode.Prod,
});
expect(devMockService.devvRedis).toBeDefined();
});

it("returns devvRedis 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.devvRedis.get("regular_key");
expect(mockContext.redis.get).toBeCalledWith("regular_key");
expect(mockContext.redis.get).toHaveBeenCalledOnce();
expect(response).toBe("real redis");
});

it("returns devvRedis 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.devvRedis.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.devvRedis.get("mocked_key");
expect(mockContext.redis.get).toBeCalledWith("mocked_key");
expect(mockContext.redis.get).toHaveBeenCalledOnce();
expect(response).toBe("real redis");
});
});
describe("redditApi", () => {
it("returns devvRedditApi", () => {
const devMockService = DevMock.createService({
context: mockContext,
mode: DevMockMode.Prod,
});
expect(devMockService.devvRedditApi).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.devvRedditApi.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.devvRedditApi.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.devvRedditApi.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 devvFetch", () => {
const devMockService = DevMock.createService({
context: mockContext,
mode: DevMockMode.Prod,
});
expect(devMockService.devvFetch).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.devvFetch(
"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.devvFetch("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 {
createDevvRedis,
isRedisOverride,
redisHandler,
} from "./redis-mock-service/index.js";
import {
createDevvRedditApi,
isRedditApiOverride,
redditApiHandler,
} from "./reddit-api-mock-service/index.js";
import {
createDevvFetch,
httpHandler,
httpResponse,
isHttpApiOverride,
} from "./http-mock-service/index.js";

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

export type DevMockService = {
devvRedis: RedisClient;
devvRedditApi: RedditAPIClient;
devvFetch: typeof fetch;
Copy link
Member

Choose a reason for hiding this comment

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

suggest omitting the name devv prefix names (I make a comment about this later that it's a new term) because I don't think it's adding much useful context here. the user only needs to know which service is which and the typing covers that.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This is done to avoid confusion with the actual instances of redis and reddit
Imagine the following scenario

const {redis, reddit} = context;
const { devvRedis, devvRedditApi, devvFetch } = DevMock.createService({...

Without the prefix there is a potential name collision

};

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

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

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

return { devvRedis, devvRedditApi, devvFetch };
};

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