Skip to content

Commit

Permalink
Improve test mocks and assertions
Browse files Browse the repository at this point in the history
  • Loading branch information
janicduplessis committed Jun 19, 2024
1 parent 40a3a6a commit bf3ef1d
Show file tree
Hide file tree
Showing 3 changed files with 95 additions and 37 deletions.
79 changes: 53 additions & 26 deletions tests/ui/PaginationTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import {act, fireEvent, render, screen} from '@testing-library/react-native';
import {addSeconds, format, subMinutes} from 'date-fns';
import React from 'react';
import Onyx from 'react-native-onyx';
import type {ApiCommand} from '@libs/API/types';
import * as Localize from '@libs/Localize';
import * as AppActions from '@userActions/App';
import * as User from '@userActions/User';
Expand Down Expand Up @@ -88,14 +87,17 @@ const USER_A_EMAIL = 'user_a@test.com';
const USER_B_ACCOUNT_ID = 2;
const USER_B_EMAIL = 'user_b@test.com';

function mockOpenReport(messageCount: number, includeCreatedAction: boolean) {
function buildReportComments(count: number, initialID: string, reverse = false) {
let currentID = parseInt(initialID, 10);
const TEN_MINUTES_AGO = subMinutes(new Date(), 10);
const actions = Object.fromEntries(
Array.from({length: messageCount}).map((_, index) => {
const created = format(addSeconds(TEN_MINUTES_AGO, 10 * index), CONST.DATE.FNS_DB_FORMAT_STRING);
return Object.fromEntries(
Array.from({length: Math.min(count, currentID)}).map(() => {
const created = format(addSeconds(TEN_MINUTES_AGO, 10 * currentID), CONST.DATE.FNS_DB_FORMAT_STRING);
const id = currentID;
currentID += reverse ? 1 : -1;
return [
`${index + 1}`,
index === 0 && includeCreatedAction
`${id}`,
id === 1
? {
reportActionID: '1',
actionName: 'CREATED' as const,
Expand All @@ -107,23 +109,44 @@ function mockOpenReport(messageCount: number, includeCreatedAction: boolean) {
},
],
}
: TestHelper.buildTestReportComment(created, USER_B_ACCOUNT_ID, `${index + 1}`),
: TestHelper.buildTestReportComment(created, USER_B_ACCOUNT_ID, `${id}`),
];
}),
);
fetchMock.mockAPICommand('OpenReport', [
}

function mockOpenReport(messageCount: number, initialID: string) {
fetchMock.mockAPICommand('OpenReport', () => [
{
onyxMethod: 'merge',
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`,
value: actions,
value: buildReportComments(messageCount, initialID),
},
]);
}

function expectAPICommandToHaveBeenCalled(commandName: ApiCommand, expectedCalls: number) {
expect(fetchMock.mock.calls.filter((c) => c[0] === `https://www.expensify.com.dev/api/${commandName}?`)).toHaveLength(expectedCalls);
function mockGetOlderActions(messageCount: number) {
fetchMock.mockAPICommand('GetOlderActions', ({reportActionID}) => [
{
onyxMethod: 'merge',
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`,
// The API also returns the action that was requested with the reportActionID.
value: buildReportComments(messageCount + 1, reportActionID),
},
]);
}

// function mockGetNewerActions(messageCount: number) {
// fetchMock.mockAPICommand('GetNewerActions', ({reportActionID}) => [
// {
// onyxMethod: 'merge',
// key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`,
// // The API also returns the action that was requested with the reportActionID.
// value: buildReportComments(messageCount + 1, reportActionID, true),
// },
// ]);
// }

/**
* Sets up a test with a logged in user. Returns the <App/> test instance.
*/
Expand Down Expand Up @@ -182,44 +205,48 @@ describe('Pagination', () => {
});

it('opens a chat and load initial messages', async () => {
mockOpenReport(5, true);
mockOpenReport(5, '5');

await signInAndGetApp();
await navigateToSidebarOption(0);

expect(getReportActions()).toHaveLength(5);
expectAPICommandToHaveBeenCalled('OpenReport', 1);
expectAPICommandToHaveBeenCalled('GetOlderActions', 0);
expectAPICommandToHaveBeenCalled('GetNewerActions', 0);
TestHelper.expectAPICommandToHaveBeenCalled('OpenReport', 1);
TestHelper.expectAPICommandToHaveBeenCalledWith('OpenReport', 0, {reportID: REPORT_ID, reportActionID: ''});
TestHelper.expectAPICommandToHaveBeenCalled('GetOlderActions', 0);
TestHelper.expectAPICommandToHaveBeenCalled('GetNewerActions', 0);

// Scrolling here should not trigger a new network request.
scrollToOffset(LIST_CONTENT_SIZE.height);
await waitForBatchedUpdatesWithAct();
scrollToOffset(0);
await waitForBatchedUpdatesWithAct();

expectAPICommandToHaveBeenCalled('OpenReport', 1);
expectAPICommandToHaveBeenCalled('GetOlderActions', 0);
expectAPICommandToHaveBeenCalled('GetNewerActions', 0);
TestHelper.expectAPICommandToHaveBeenCalled('OpenReport', 1);
TestHelper.expectAPICommandToHaveBeenCalled('GetOlderActions', 0);
TestHelper.expectAPICommandToHaveBeenCalled('GetNewerActions', 0);
});

it('opens a chat and load older messages', async () => {
mockOpenReport(5, false);
mockOpenReport(5, '8');
mockGetOlderActions(5);

await signInAndGetApp();
await navigateToSidebarOption(0);

expect(getReportActions()).toHaveLength(5);
expectAPICommandToHaveBeenCalled('OpenReport', 1);
expectAPICommandToHaveBeenCalled('GetOlderActions', 0);
expectAPICommandToHaveBeenCalled('GetNewerActions', 0);
TestHelper.expectAPICommandToHaveBeenCalled('OpenReport', 1);
TestHelper.expectAPICommandToHaveBeenCalledWith('OpenReport', 0, {reportID: REPORT_ID, reportActionID: ''});
TestHelper.expectAPICommandToHaveBeenCalled('GetOlderActions', 0);
TestHelper.expectAPICommandToHaveBeenCalled('GetNewerActions', 0);

// Scrolling here should trigger a new network request.
scrollToOffset(LIST_CONTENT_SIZE.height);
await waitForBatchedUpdatesWithAct();

expectAPICommandToHaveBeenCalled('OpenReport', 1);
expectAPICommandToHaveBeenCalled('GetOlderActions', 1);
expectAPICommandToHaveBeenCalled('GetNewerActions', 0);
TestHelper.expectAPICommandToHaveBeenCalled('OpenReport', 1);
TestHelper.expectAPICommandToHaveBeenCalled('GetOlderActions', 1);
TestHelper.expectAPICommandToHaveBeenCalledWith('GetOlderActions', 0, {reportID: REPORT_ID, reportActionID: '4'});
TestHelper.expectAPICommandToHaveBeenCalled('GetNewerActions', 0);
});
});
52 changes: 41 additions & 11 deletions tests/utils/TestHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type * as NativeNavigation from '@react-navigation/native';
import {Str} from 'expensify-common';
import {Linking} from 'react-native';
import Onyx from 'react-native-onyx';
import type {ApiCommand, ApiRequestCommandParameters} from '@libs/API/types';
import * as Pusher from '@libs/Pusher/pusher';
import PusherConnectionManager from '@libs/PusherConnectionManager';
import CONFIG from '@src/CONFIG';
Expand All @@ -14,18 +15,20 @@ import appSetup from '@src/setup';
import type {Response as OnyxResponse, PersonalDetails, Report} from '@src/types/onyx';
import waitForBatchedUpdates from './waitForBatchedUpdates';

console.debug = () => {};

type MockFetch = ReturnType<typeof jest.fn> & {
pause: () => void;
fail: () => void;
succeed: () => void;
resume: () => Promise<void>;
mockAPICommand: (command: string, response: OnyxResponse['onyxData']) => void;
mockAPICommand: <TCommand extends ApiCommand>(command: TCommand, responseHandler: (params: ApiRequestCommandParameters[TCommand]) => OnyxResponse['onyxData']) => void;
};

type QueueItem = {
resolve: (value: Partial<Response> | PromiseLike<Partial<Response>>) => void;
input: RequestInfo;
init?: RequestInit;
options?: RequestInit;
};

type FormData = {
Expand Down Expand Up @@ -186,11 +189,12 @@ function signOutTestUser() {
*/
function getGlobalFetchMock(): typeof fetch {
let queue: QueueItem[] = [];
let responses = new Map<string, unknown>();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let responses = new Map<string, (params: any) => OnyxResponse['onyxData']>();
let isPaused = false;
let shouldFail = false;

const getResponse = (input: RequestInfo): Partial<Response> =>
const getResponse = (input: RequestInfo, options?: RequestInit): Partial<Response> =>
shouldFail
? {
ok: true,
Expand All @@ -202,20 +206,22 @@ function getGlobalFetchMock(): typeof fetch {
const commandMatch = typeof input === 'string' ? input.match(/https:\/\/www.expensify.com.dev\/api\/(\w+)\?/) : null;
const command = commandMatch ? commandMatch[1] : null;

if (command && responses.has(command)) {
return Promise.resolve({jsonCode: 200, onyxData: responses.get(command)});
const responseHandler = command ? responses.get(command) : null;
if (responseHandler) {
const requestData = options?.body instanceof FormData ? Object.fromEntries(options.body) : {};
return Promise.resolve({jsonCode: 200, onyxData: responseHandler(requestData)});
}

return Promise.resolve({jsonCode: 200});
},
};

const mockFetch = jest.fn().mockImplementation((input: RequestInfo) => {
const mockFetch = jest.fn().mockImplementation((input: RequestInfo, options?: RequestInit) => {
if (!isPaused) {
return Promise.resolve(getResponse(input));
return Promise.resolve(getResponse(input, options));
}
return new Promise((resolve) => {
queue.push({resolve, input});
queue.push({resolve, input, options});
});
}) as MockFetch;

Expand All @@ -237,8 +243,8 @@ function getGlobalFetchMock(): typeof fetch {
};
mockFetch.fail = () => (shouldFail = true);
mockFetch.succeed = () => (shouldFail = false);
mockFetch.mockAPICommand = (command: string, response: OnyxResponse['onyxData']) => {
responses.set(command, response);
mockFetch.mockAPICommand = <TCommand extends ApiCommand>(command: TCommand, responseHandler: (params: ApiRequestCommandParameters[TCommand]) => OnyxResponse['onyxData']): void => {
responses.set(command, responseHandler);
};
return mockFetch as typeof fetch;
}
Expand All @@ -256,6 +262,28 @@ function setupGlobalFetchMock(): MockFetch {
return mockFetch as MockFetch;
}

function getFetchMockCalls(commandName: ApiCommand) {
return (global.fetch as MockFetch).mock.calls.filter((c) => c[0] === `https://www.expensify.com.dev/api/${commandName}?`);
}

/**
* Assertion helper to validate that a command has been called a specific number of times.
*/
function expectAPICommandToHaveBeenCalled(commandName: ApiCommand, expectedCalls: number) {
expect(getFetchMockCalls(commandName)).toHaveLength(expectedCalls);
}

/**
* Assertion helper to validate that a command has been called with specific parameters.
*/
function expectAPICommandToHaveBeenCalledWith<TCommand extends ApiCommand>(commandName: TCommand, callIndex: number, expectedParams: ApiRequestCommandParameters[TCommand]) {
const call = getFetchMockCalls(commandName).at(callIndex);
expect(call).toBeTruthy();
const body = call?.at(1)?.body;
const params = body instanceof FormData ? Object.fromEntries(body) : {};
expect(params).toEqual(expect.objectContaining(expectedParams));
}

function setPersonalDetails(login: string, accountID: number) {
Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, {
[accountID]: buildPersonalDetails(login, accountID),
Expand Down Expand Up @@ -292,6 +320,8 @@ export {
getGlobalFetchMock,
setupApp,
setupGlobalFetchMock,
expectAPICommandToHaveBeenCalled,
expectAPICommandToHaveBeenCalledWith,
setPersonalDetails,
signInWithTestUser,
signOutTestUser,
Expand Down
1 change: 1 addition & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"compilerOptions": {
"module": "commonjs",
"types": ["react-native", "jest"],
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"isolatedModules": true,
"strict": true,
"allowSyntheticDefaultImports": true,
Expand Down

0 comments on commit bf3ef1d

Please sign in to comment.