diff --git a/src/commands/__tests__/reactQuery.test.ts b/src/commands/__tests__/reactQuery.test.ts deleted file mode 100644 index 17c2e92..0000000 --- a/src/commands/__tests__/reactQuery.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { fs, vol } from 'memfs'; -import { Mock, afterEach, expect, test, vi } from 'vitest'; -import addDependency from '../../util/addDependency'; -import print from '../../util/print'; -import addTypescript from '../typescript'; - -vi.mock('../../util/addDependency'); -vi.mock('../../util/print', () => ({ default: vi.fn() })); - -afterEach(() => { - vol.reset(); - (print as Mock).mockReset(); -}); - -test('exits with message if tsconfig.json already exists', async () => { - const json = { - 'package.json': JSON.stringify({ - scripts: {}, - dependencies: {}, - }), - 'tsconfig.json': '1', - }; - vol.fromJSON(json, './'); - - await addTypescript(); - expect(print).toHaveBeenCalledWith( - expect.stringMatching(/tsconfig\.json already exists/), - ); - - // doesn't modify - expect(fs.readFileSync('tsconfig.json', 'utf8')).toEqual('1'); -}); - -test('writes new tsconfig.json, adds dependencies', async () => { - vol.fromJSON({ - 'package.json': JSON.stringify({ - scripts: {}, - dependencies: { - expo: '1.0.0', - }, - }), - }); - - await addTypescript(); - - expect(addDependency).toHaveBeenCalledWith('typescript @types/react', { - dev: true, - }); - - expect(fs.readFileSync('tsconfig.json', 'utf8')).toMatch( - '"extends": "expo/tsconfig.base"', - ); - - expect(print).not.toHaveBeenCalledWith( - expect.stringMatching(/already exists/), - ); -}); - -test("doesn't extend expo/tsconfig.base if not an Expo project", async () => { - vol.fromJSON({ - 'package.json': JSON.stringify({ - scripts: {}, - dependencies: {}, - }), - }); - - await addTypescript(); - - expect(addDependency).toHaveBeenCalledWith('typescript @types/react', { - dev: true, - }); - - expect(fs.readFileSync('tsconfig.json', 'utf8')).not.toMatch( - 'expo/tsconfig.base', - ); -}); diff --git a/src/commands/createApp.ts b/src/commands/createApp.ts index 5e4e734..209b64c 100644 --- a/src/commands/createApp.ts +++ b/src/commands/createApp.ts @@ -102,7 +102,7 @@ async function printIntro(appName: string) { - ESLint - Jest, React Native Testing Library - React Navigation - - Intuitive directory structure + - TanStack Query (formerly known as React Query) `); if (!globals.interactive) { diff --git a/src/util/addToGitignore.ts b/src/util/addToGitignore.ts index fe11220..7fb16cf 100644 --- a/src/util/addToGitignore.ts +++ b/src/util/addToGitignore.ts @@ -1,12 +1,8 @@ -import fs from 'fs-extra'; -import path from 'path'; -import getProjectDir from './getProjectDir'; +import appendToFile from './appendToFile'; + /** * lines should be separated by newlines */ export default async function addToGitignore(lines: string) { - return fs.appendFile( - path.join(await getProjectDir(), '.gitignore'), - `\n${lines}`, - ); + return appendToFile('.gitignore', lines); } diff --git a/src/util/appendToFile.ts b/src/util/appendToFile.ts new file mode 100644 index 0000000..0ee864f --- /dev/null +++ b/src/util/appendToFile.ts @@ -0,0 +1,12 @@ +import fs from 'fs-extra'; +import path from 'path'; +import getProjectDir from './getProjectDir'; +/** + * lines should be separated by newlines + */ +export default async function appendToFile(filename: string, lines: string) { + return fs.appendFile( + path.join(await getProjectDir(), filename), + `\n${lines}`, + ); +} diff --git a/templates/boilerplate/App.tsx b/templates/boilerplate/App.tsx index 76b2838..f8d50ac 100644 --- a/templates/boilerplate/App.tsx +++ b/templates/boilerplate/App.tsx @@ -1,11 +1,16 @@ import { NavigationContainer } from '@react-navigation/native'; - +import { QueryClientProvider } from '@tanstack/react-query'; import Providers, { Provider } from 'src/components/Providers'; import RootNavigator from 'src/navigators/RootNavigator'; +import queryClient from 'src/util/api/queryClient'; -// Add providers to this array +// Add providers to this array. They will be wrapped around the app, with the +// first items in the array wrapping the last items in the array. const providers: Provider[] = [ (children) => {children}, + (children) => ( + {children} + ), // CODEGEN:BELT:PROVIDERS - do not remove ]; diff --git a/templates/boilerplate/jest.setup.js b/templates/boilerplate/jest.setup.js index 7d448c7..00da6e6 100644 --- a/templates/boilerplate/jest.setup.js +++ b/templates/boilerplate/jest.setup.js @@ -2,6 +2,8 @@ import '@testing-library/jest-native/extend-expect'; import { configure } from '@testing-library/react-native'; import mockSafeAreaContext from 'react-native-safe-area-context/jest/mock'; import mockBackHandler from 'react-native/Libraries/Utilities/__mocks__/BackHandler.js'; +import server from 'src/test/server'; +import queryClient from 'src/util/api/queryClient'; beforeEach(() => { jest.clearAllMocks(); @@ -35,6 +37,18 @@ jest.mock('@react-native-async-storage/async-storage', () => jest.mock('react-native-keyboard-aware-scroll-view'); +// listen with MSW server. Individual tests can pass mocks to 'render' function +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); +afterAll(() => server.close()); + +beforeEach(() => { + server.resetHandlers(); +}); + +afterEach(() => { + queryClient.clear(); +}); + // configure debug output for RN Testing Library // is way too verbose by default. Only include common // props that might affect test failure. diff --git a/templates/boilerplate/package.json b/templates/boilerplate/package.json index fc062a1..a9d5598 100644 --- a/templates/boilerplate/package.json +++ b/templates/boilerplate/package.json @@ -23,10 +23,13 @@ "@react-navigation/bottom-tabs": "^6.5.20", "@react-navigation/native": "^6.1.10", "@react-navigation/native-stack": "^6.9.18", + "@tanstack/react-query": "^5.32.1", + "axios": "^1.6.8", "expo": "^50.0.17", "expo-status-bar": "~1.11.1", "jest": "^29.3.1", "jest-expo": "~50.0.2", + "msw": "^2.2.14", "react": "18.2.0", "react-native": "0.73.6", "react-native-keyboard-aware-scrollview": "^2.1.0", @@ -40,6 +43,7 @@ "@thoughtbot/eslint-config": "^1.0.2", "@types/jest": "^29.5.12", "@types/react": "~18.2.73", + "@types/react-test-renderer": "^18.0.7", "babel-jest": "^29.7.0", "create-belt-app": "^0.4.0", "eslint": "^8.56.0", diff --git a/templates/boilerplate/src/__tests__/App.test.tsx b/templates/boilerplate/src/__tests__/App.test.tsx index 80e945f..61ebeca 100644 --- a/templates/boilerplate/src/__tests__/App.test.tsx +++ b/templates/boilerplate/src/__tests__/App.test.tsx @@ -1,10 +1,31 @@ import { screen } from '@testing-library/react-native'; -import RootNavigator from 'src/navigators/RootNavigator'; -import render from 'src/test/render'; +import mock from 'src/test/mock'; +import { renderApplication } from 'src/test/render'; test('renders', async () => { + // We would not normally recommend fake timers, but the tests are currently + // throwing a "not wrapped in act" warning after this test finishes. One + // option is to put a `await waitForUpdates()` at the end of the test, but + // fake timers also work here until we find a better solution. The stack trace + // seems to point to React Navigation bottom tabs. jest.useFakeTimers(); - render(); - expect(await screen.findByText(/Open up App.tsx/)).toBeDefined(); + + const mocks = [mockCoffees()]; + + renderApplication({ mocks }); + + expect(await screen.findByRole('header', { name: 'Mocha' })).toBeDefined(); }); + +function mockCoffees() { + return mock.get('coffee/hot', { + response: [ + { + id: 1, + title: 'Mocha', + image: 'htps://placehold.it/200x200', + }, + ], + }); +} diff --git a/templates/boilerplate/src/components/ExampleCoffees.tsx b/templates/boilerplate/src/components/ExampleCoffees.tsx new file mode 100644 index 0000000..bf1a228 --- /dev/null +++ b/templates/boilerplate/src/components/ExampleCoffees.tsx @@ -0,0 +1,49 @@ +import { useQuery } from '@tanstack/react-query'; +import { FlatList, Image, Text, View } from 'react-native'; +import api, { Coffee as CoffeeType } from 'src/util/api/api'; + +// TODO: sample data, remove +export default function ExampleCoffees() { + const { data } = useQuery({ queryKey: ['coffee'], queryFn: api.coffee }); + + return ( + <> + Coffees + } + keyExtractor={(item) => item.id.toString()} + style={{ flexGrow: 0 }} + /> + + ); +} + +function Coffee({ coffee }: { coffee: CoffeeType }) { + const { title, image } = coffee; + + return ( + + + {title} + + + + ); +} diff --git a/templates/boilerplate/src/screens/HomeScreen/HomeScreen.tsx b/templates/boilerplate/src/screens/HomeScreen/HomeScreen.tsx index ba298d5..ae7b666 100644 --- a/templates/boilerplate/src/screens/HomeScreen/HomeScreen.tsx +++ b/templates/boilerplate/src/screens/HomeScreen/HomeScreen.tsx @@ -1,6 +1,7 @@ import { useNavigation } from '@react-navigation/native'; import { StatusBar } from 'expo-status-bar'; import { Button, StyleSheet, Text, View } from 'react-native'; +import ExampleCoffees from 'src/components/ExampleCoffees'; import { HomeScreenProp } from 'src/navigators/navigatorTypes'; export default function HomeScreen() { @@ -14,6 +15,7 @@ export default function HomeScreen() { onPress={() => navigation.navigate('Information', { owner: 'Will' })} /> + ); } diff --git a/templates/boilerplate/src/test/mock.ts b/templates/boilerplate/src/test/mock.ts new file mode 100644 index 0000000..f424238 --- /dev/null +++ b/templates/boilerplate/src/test/mock.ts @@ -0,0 +1,148 @@ +import { + DefaultBodyType, + HttpResponse, + PathParams, + StrictRequest, + http, +} from 'msw'; +import { BASE_URL, RequestMethod } from 'src/util/api/api'; + +type RequestParams = Record< + string, + string | number | undefined | (string | number)[] +>; +type MockRequestParams = { + method: RequestMethod; + response?: TData; + headers?: Record; + params?: Partial; + status?: number; + delay?: number; + baseUrl?: string; +}; + +function mockRequest( + path: string, + { + method, + status = 200, + response, + headers, + params, + baseUrl = BASE_URL, + }: MockRequestParams, +) { + const methodName = method.toLowerCase() as Lowercase; + return http[methodName]( + `${baseUrl}/${path}`, + async (info) => { + const { request, params: actualParams } = info; + validateHeaders(headers, request); + await validateParams(params, actualParams, request); + + const responseString = + typeof response === 'string' ? response : JSON.stringify(response); + return new HttpResponse(responseString, { status }); + }, + { once: true }, + ); +} + +function validateHeaders( + expectedHeaders: Record | undefined, + req: StrictRequest, +) { + if (!expectedHeaders) { + return; + } + + Object.entries(expectedHeaders).forEach(([key, value]) => { + try { + expect(req.headers.get(key)).toEqual(value); + } catch (e) { + handleAndThrowError(req, e, 'the headers did not match expectation'); + } + }); +} + +async function validateParams( + expectedParams: TParams | undefined, + actualParams: PathParams, + req: StrictRequest, +) { + if (!expectedParams) { + return; + } + + const searchParams = Object.fromEntries(new URL(req.url).searchParams); + const params = Object.keys(searchParams).length ? searchParams : actualParams; + + try { + expect(params).toMatchObject(expectedParams); + } catch (e) { + handleAndThrowError(req, e, 'the params did not match expectation'); + } +} + +function handleAndThrowError( + request: StrictRequest, + e: unknown, + message: string, +) { + const error = e as Error; + if (error.message) { + error.message = `Mock for ${request.method} ${ + request.url + } was called, but ${message}. Verify that the mocks provided to the test are correct.\n\n${ + error.message + }.\n\nThis error occurred in test: ${ + expect.getState().testPath || '' + }. Test name: '${expect.getState().currentTestName || 'unknown'}'`; + } + // eslint-disable-next-line no-console + console.error(error.stack); + throw error; +} + +export type MockParams = Omit< + MockRequestParams, + 'method' +>; + +/** + * mock requests for tests + * Eg. usage: + * const mocks = [ + * mock.post('/user/login', { response: { firstName: 'Debra' }}) + * ]) + * + * render(\, { mocks }) + */ +const mock = { + /** + * mock a GET request to the specified path. + * If params are passed, will throw an error if request params do not match + * */ + get: ( + path: string, + params: MockParams = {}, + ) => mockRequest(path, { ...params, method: 'GET' }), + /** + * mock a POST request to the specified path + * if params are passed, will throw an error if request params do not match + * */ + post: ( + path: string, + params: MockParams = {}, + ) => mockRequest(path, { ...params, method: 'POST' }), + /** + * mock a DELETE request to the specified path + * if params are passed, will throw an error if request params do not match + * */ + delete: ( + path: string, + params: MockParams = {}, + ) => mockRequest(path, { ...params, method: 'DELETE' }), +}; + +export default mock; diff --git a/templates/boilerplate/src/test/render.tsx b/templates/boilerplate/src/test/render.tsx index 2a7c909..0a346f7 100644 --- a/templates/boilerplate/src/test/render.tsx +++ b/templates/boilerplate/src/test/render.tsx @@ -1,15 +1,44 @@ import { NavigationContainer } from '@react-navigation/native'; +import { QueryClientProvider } from '@tanstack/react-query'; import { RenderAPI, // eslint-disable-next-line no-restricted-imports render as TestingLibraryRender, } from '@testing-library/react-native'; +import { RequestHandler } from 'msw'; import { ReactElement } from 'react'; +import Providers, { Provider } from 'src/components/Providers'; +import RootNavigator from 'src/navigators/RootNavigator'; +import queryClient from 'src/util/api/queryClient'; +import server from './server'; + +export type RenderOptions = { + mocks?: Array; +}; // TODO: this will become customized as the codebase progresses, so our // tests can be wrapped with appropriate providers, mocks can be supplied, etc -export default function render(element: ReactElement): RenderAPI { +export default function render( + element: ReactElement, + { mocks }: RenderOptions = {}, +): RenderAPI { + if (mocks) { + server.use(...mocks); + } + + const providers: Provider[] = [ + (children) => ( + {children} + ), + (children) => {children}, + // CODEGEN:BELT:PROVIDERS - do not remove + ]; + return TestingLibraryRender( - {element}, + {element}, ); } + +export function renderApplication(options: RenderOptions = {}) { + return render(, options); +} diff --git a/templates/boilerplate/src/test/server.ts b/templates/boilerplate/src/test/server.ts new file mode 100644 index 0000000..dbe8f29 --- /dev/null +++ b/templates/boilerplate/src/test/server.ts @@ -0,0 +1,9 @@ +import { setupServer } from 'msw/node'; + +/** + * MSW server for mocking network requests + * server is started in jest.setup.js + * individual tests can pass mocks to 'render' function + */ +const server = setupServer(); +export default server; diff --git a/templates/boilerplate/src/test/sleep.ts b/templates/boilerplate/src/test/sleep.ts new file mode 100644 index 0000000..6172333 --- /dev/null +++ b/templates/boilerplate/src/test/sleep.ts @@ -0,0 +1,5 @@ +export default function sleep(ms: number) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} diff --git a/templates/boilerplate/src/test/waitForUpdates.ts b/templates/boilerplate/src/test/waitForUpdates.ts new file mode 100644 index 0000000..20000bd --- /dev/null +++ b/templates/boilerplate/src/test/waitForUpdates.ts @@ -0,0 +1,12 @@ +import { act } from '@testing-library/react-native'; +import sleep from './sleep'; + +/** + * Wait a specified time, wrapped in act + * Usually, it is better to use waitFor or a findBy* matcher, + * but this is sometimes required + * @param time + */ +export default async function waitForUpdates(time = 2) { + return act(() => sleep(time)); +} diff --git a/templates/boilerplate/src/util/api/api.ts b/templates/boilerplate/src/util/api/api.ts new file mode 100644 index 0000000..13d8203 --- /dev/null +++ b/templates/boilerplate/src/util/api/api.ts @@ -0,0 +1,44 @@ +import axios from 'axios'; + +export type RequestMethod = 'GET' | 'POST' | 'DELETE'; + +// Consider moving this out into environment configuration +// to support multiple environments +export const BASE_URL = 'https://api.sampleapis.com'; + +type Params = { + path: string; + method: RequestMethod; + params?: unknown; + parseJson?: boolean; + baseUrl?: string; +}; + +async function makeRequest(options: Params): Promise { + const { path, method, params, baseUrl = BASE_URL } = options; + + const response = await axios(`${baseUrl}/${path}`, { + method, + headers: { + 'Content-Type': 'application/json', + }, + [method === 'GET' ? 'params' : 'data']: params, + }); + + return response.data; +} + +const api = { + coffee: () => makeRequest({ path: 'coffee/hot', method: 'GET' }), +}; + +// TODO: sample data, remove +export type Coffee = { + title: string; + description: string; + /** the url to the image */ + image: string; + id: number; +}; + +export default api; diff --git a/templates/boilerplate/src/util/api/queryClient.ts b/templates/boilerplate/src/util/api/queryClient.ts new file mode 100644 index 0000000..e358b6f --- /dev/null +++ b/templates/boilerplate/src/util/api/queryClient.ts @@ -0,0 +1,11 @@ +import { QueryClient } from '@tanstack/react-query'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}); + +export default queryClient; diff --git a/templates/testingLibrary/src/test/render.tsx b/templates/testingLibrary/src/test/render.tsx index 2a7c909..1b1d322 100644 --- a/templates/testingLibrary/src/test/render.tsx +++ b/templates/testingLibrary/src/test/render.tsx @@ -9,7 +9,5 @@ import { ReactElement } from 'react'; // TODO: this will become customized as the codebase progresses, so our // tests can be wrapped with appropriate providers, mocks can be supplied, etc export default function render(element: ReactElement): RenderAPI { - return TestingLibraryRender( - {element}, - ); + return TestingLibraryRender(element); }