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);
}