From 0afb278eefbef0a753e1d7f3863bd2a2bb9f0c2b Mon Sep 17 00:00:00 2001 From: Jonas Braathen Date: Sat, 5 Sep 2026 19:21:52 +0200 Subject: [PATCH] Migrate to @testing-library/react-native 14 --- jest.setup.js | 6 ++ package-lock.json | 62 ++++++++++++++---- package.json | 4 +- src/App.test.tsx | 2 +- src/auth/AuthContext.test.tsx | 33 ++++++---- src/modules/about/About.test.tsx | 4 +- src/modules/events/EventList.test.tsx | 16 ++--- src/modules/login/Login.test.tsx | 44 ++++++------- src/modules/login/Login.tsx | 4 +- src/modules/membership/Membership.test.tsx | 23 ++++--- src/modules/membership/Membership.tsx | 2 +- src/modules/membership/Proof.test.tsx | 43 +++++++------ src/modules/membership/Proof.tsx | 8 ++- src/modules/membership/UserDelete.test.tsx | 20 +++--- src/modules/membership/UserDelete.tsx | 4 +- src/modules/register/UserRegister.test.tsx | 73 ++++++++-------------- src/modules/register/UserRegister.tsx | 28 ++------- src/modules/register/useUserRegister.tsx | 15 ----- src/testUtils.tsx | 4 +- 19 files changed, 203 insertions(+), 192 deletions(-) diff --git a/jest.setup.js b/jest.setup.js index becc0d6..f0120d5 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -29,6 +29,12 @@ jest.mock('@react-native-async-storage/async-storage', () => jest.mock('@stripe/stripe-react-native', () => require('@stripe/stripe-react-native/jest/mock.js')); +// The preset's RefreshControl mock drops its props, so onRefresh and testID never reach the host tree +jest.mock('react-native/Libraries/Components/RefreshControl/RefreshControl', () => { + const React = require('react'); + return { __esModule: true, default: (props) => React.createElement('RCTRefreshControl', props) }; +}); + // Reanimated (even its mock) initializes the native side of worklets at import // time; substitute the mock that ships with react-native-worklets. jest.mock('react-native-worklets', () => require('react-native-worklets/src/mock')); diff --git a/package-lock.json b/package-lock.json index cf09b05..b2d1c82 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,7 +44,7 @@ }, "devDependencies": { "@babel/core": "^7.24.9", - "@testing-library/react-native": "^13.3.3", + "@testing-library/react-native": "^14.0.1", "@types/jest": "^29.5.14", "@types/react": "~19.2.10", "eslint": "^9.39.5", @@ -54,7 +54,7 @@ "jest": "^29.7.0", "jest-expo": "~57.0.5", "prettier": "^3.9.6", - "react-test-renderer": "19.2.3", + "test-renderer": "1.2", "typescript": "~6.0.3" } }, @@ -4551,25 +4551,25 @@ } }, "node_modules/@testing-library/react-native": { - "version": "13.3.3", - "resolved": "https://registry.npmjs.org/@testing-library/react-native/-/react-native-13.3.3.tgz", - "integrity": "sha512-k6Mjsd9dbZgvY4Bl7P1NIpePQNi+dfYtlJ5voi9KQlynxSyQkfOgJmYGCYmw/aSgH/rUcFvG8u5gd4npzgRDyg==", + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/react-native/-/react-native-14.0.1.tgz", + "integrity": "sha512-2r2e2y8SkUNRWKRXlUGqDYunB+AkbdXUPn49Bs5rpv9oxRb0K3USBVugbPbAKJjfj4w5Ba91NJ8LcQCZyopUZA==", "dev": true, "license": "MIT", "dependencies": { - "jest-matcher-utils": "^30.0.5", + "jest-matcher-utils": "^30.4.1", "picocolors": "^1.1.1", - "pretty-format": "^30.0.5", + "pretty-format": "^30.4.1", "redent": "^3.0.0" }, "engines": { - "node": ">=18" + "node": "^22.13.0 || >=24" }, "peerDependencies": { "jest": ">=29.0.0", - "react": ">=18.2.0", - "react-native": ">=0.71", - "react-test-renderer": ">=18.2.0" + "react": ">=19.0.0", + "react-native": ">=0.78", + "test-renderer": "^1.0.0" }, "peerDependenciesMeta": { "jest": { @@ -4780,6 +4780,16 @@ "csstype": "^3.2.2" } }, + "node_modules/@types/react-reconciler": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.33.0.tgz", + "integrity": "sha512-HZOXsKT0tGI9LlUw2LuedXsVeB88wFa536vVL0M6vE8zN63nI+sSr1ByxmPToP5K5bukaVscyeCJcF9guVNJ1g==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, "node_modules/@types/react-test-renderer": { "version": "19.1.0", "resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-19.1.0.tgz", @@ -15657,6 +15667,36 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/test-renderer": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/test-renderer/-/test-renderer-1.2.0.tgz", + "integrity": "sha512-JYiEGbgBGtmHAWX8Kf99gGRL1HtjcRVHLrmJNTZL4vFs9XrnWcuL45Iszw/pO4094+JR7havSrN+ds6YTOpSQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react-reconciler": "~0.33.0", + "react-reconciler": "~0.33.0" + }, + "peerDependencies": { + "react": "^19.0.0" + } + }, + "node_modules/test-renderer/node_modules/react-reconciler": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.33.0.tgz", + "integrity": "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^19.2.0" + } + }, "node_modules/throat": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/throat/-/throat-5.0.0.tgz", diff --git a/package.json b/package.json index cb8b7a0..8d52506 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ }, "devDependencies": { "@babel/core": "^7.24.9", - "@testing-library/react-native": "^13.3.3", + "@testing-library/react-native": "^14.0.1", "@types/jest": "^29.5.14", "@types/react": "~19.2.10", "eslint": "^9.39.5", @@ -61,7 +61,7 @@ "jest": "^29.7.0", "jest-expo": "~57.0.5", "prettier": "^3.9.6", - "react-test-renderer": "19.2.3", + "test-renderer": "~1.2.0", "typescript": "~6.0.3" }, "expo": { diff --git a/src/App.test.tsx b/src/App.test.tsx index 25270d8..c90f8ca 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -15,7 +15,7 @@ jest.mock('@stripe/stripe-react-native', () => ({ afterEach(() => appQueryClient.clear()); it('renders the whole app up to the navigation tabs', async () => { - const { findByText } = render(); + const { findByText } = await render(); // A tab label proves the auth bootstrap finished and Navigation mounted expect(await findByText('Medlemskap')).toBeTruthy(); diff --git a/src/auth/AuthContext.test.tsx b/src/auth/AuthContext.test.tsx index f2c62fb..3c7fa15 100644 --- a/src/auth/AuthContext.test.tsx +++ b/src/auth/AuthContext.test.tsx @@ -4,6 +4,7 @@ import { act, renderHook, waitFor } from '@testing-library/react-native'; import React from 'react'; import { AuthProvider, useAuth } from '@/auth/AuthContext'; +import * as tokenStorage from '@/auth/tokenStorage'; import { queryKeys } from '@/queries/queryKeys'; const TOKEN_KEY = 'cnapp.authToken'; @@ -13,7 +14,7 @@ const grisen = { username: 'grisen', email: 'grisen@example.test', is_member: tr // gcTime: Infinity so no GC timers keep Jest alive const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { gcTime: Infinity } } }); -const renderAuth = ({ +const renderAuth = async ({ initialToken, queryClient = createQueryClient(), }: { initialToken?: string | null; queryClient?: QueryClient } = {}) => { @@ -22,7 +23,7 @@ const renderAuth = ({ {children} ); - return { queryClient, ...renderHook(() => useAuth(), { wrapper }) }; + return { queryClient, ...(await renderHook(() => useAuth(), { wrapper })) }; }; beforeEach(async () => { @@ -32,19 +33,25 @@ beforeEach(async () => { }); it('renders nothing until the stored session is restored', async () => { - await AsyncStorage.setItem(TOKEN_KEY, 'token-123'); + // Hold the token read so the closed gate is still observable after the async render + let releaseToken!: (token: string | null) => void; + jest + .spyOn(tokenStorage, 'getStoredToken') + .mockImplementationOnce(() => new Promise((resolve) => (releaseToken = resolve))); - const { result } = renderAuth(); + const { result } = await renderAuth(); // The gate: children (and thus the hook) have not rendered yet expect(result.current).toBeNull(); - await waitFor(() => expect(result.current).not.toBeNull()); + await act(async () => { + releaseToken('token-123'); + }); expect(result.current.token).toBe('token-123'); expect(result.current.isAuthenticated).toBe(true); }); it('starts logged out when nothing is stored', async () => { - const { result } = renderAuth(); + const { result } = await renderAuth(); await waitFor(() => expect(result.current).not.toBeNull()); expect(result.current.token).toBeNull(); @@ -57,7 +64,7 @@ it('runs the redux-persist migration and seeds the cached user', async () => { JSON.stringify({ userToken: JSON.stringify('9a8b'), user: JSON.stringify(grisen) }), ); - const { result, queryClient } = renderAuth(); + const { result, queryClient } = await renderAuth(); await waitFor(() => expect(result.current?.token).toBe('9a8b')); expect(queryClient.getQueryData(queryKeys.user)).toEqual(grisen); @@ -65,9 +72,9 @@ it('runs the redux-persist migration and seeds the cached user', async () => { }); it('signIn stores the token and flips isAuthenticated', async () => { - const { result } = renderAuth({ initialToken: null }); + const { result } = await renderAuth({ initialToken: null }); - act(() => result.current.signIn('token-1')); + await act(() => result.current.signIn('token-1')); expect(result.current.token).toBe('token-1'); expect(result.current.isAuthenticated).toBe(true); @@ -79,8 +86,8 @@ it('signOut clears the token and drops the cached user', async () => { queryClient.setQueryData(queryKeys.user, grisen); await AsyncStorage.setItem(TOKEN_KEY, 'token-1'); - const { result } = renderAuth({ initialToken: 'token-1', queryClient }); - act(() => result.current.signOut()); + const { result } = await renderAuth({ initialToken: 'token-1', queryClient }); + await act(() => result.current.signOut()); expect(result.current.token).toBeNull(); expect(result.current.isAuthenticated).toBe(false); @@ -88,10 +95,10 @@ it('signOut clears the token and drops the cached user', async () => { await waitFor(async () => expect(await AsyncStorage.getItem(TOKEN_KEY)).toBeNull()); }); -it('skips the storage bootstrap when given an initialToken', () => { +it('skips the storage bootstrap when given an initialToken', async () => { const getItemSpy = jest.spyOn(AsyncStorage, 'getItem'); - const { result } = renderAuth({ initialToken: 'token-x' }); + const { result } = await renderAuth({ initialToken: 'token-x' }); // Synchronously ready: no gate, no storage reads expect(result.current.token).toBe('token-x'); diff --git a/src/modules/about/About.test.tsx b/src/modules/about/About.test.tsx index 5ae5b7f..57711eb 100644 --- a/src/modules/about/About.test.tsx +++ b/src/modules/about/About.test.tsx @@ -3,8 +3,8 @@ import React from 'react'; import About from '@/modules/about/About'; import { renderWithProviders } from '@/testUtils'; -it('renders the about screen', () => { - const { getByText } = renderWithProviders(); +it('renders the about screen', async () => { + const { getByText } = await renderWithProviders(); expect(getByText('Åpningstider')).toBeTruthy(); expect(getByText('Ta kontakt')).toBeTruthy(); diff --git a/src/modules/events/EventList.test.tsx b/src/modules/events/EventList.test.tsx index 1a6289c..5ed7bcc 100644 --- a/src/modules/events/EventList.test.tsx +++ b/src/modules/events/EventList.test.tsx @@ -47,7 +47,7 @@ const event = { it('formats the next occurrence as a Norwegian date with the venue', async () => { mockFetch.mockResolvedValue({ json: async () => ({ events: [event] }) }); - const { findByText } = renderWithProviders(); + const { findByText } = await renderWithProviders(); expect(await findByText('lø. 1. aug. 2026 kl. 19:00 i Storsalen')).toBeTruthy(); }); @@ -59,7 +59,7 @@ it('formats weekday and month abbreviations across the year', async () => { ]; mockFetch.mockResolvedValue({ json: async () => ({ events }) }); - const { findByText } = renderWithProviders(); + const { findByText } = await renderWithProviders(); expect(await findByText('on. 30. des. 2026 kl. 18:00 i Storsalen')).toBeTruthy(); expect(await findByText('ma. 17. mai 2027 kl. 13:00 i Storsalen')).toBeTruthy(); @@ -70,7 +70,7 @@ it('renders venue-local (Oslo) time whatever the API offset and device timezone' const events = [{ ...event, nextOccurrence: { ...event.nextOccurrence, start: '2026-08-01T17:00:00Z' } }]; mockFetch.mockResolvedValue({ json: async () => ({ events }) }); - const { findByText } = renderWithProviders(); + const { findByText } = await renderWithProviders(); expect(await findByText('lø. 1. aug. 2026 kl. 19:00 i Storsalen')).toBeTruthy(); }); @@ -78,7 +78,7 @@ it('renders venue-local (Oslo) time whatever the API offset and device timezone' it('prefixes the time with Neste: when the event has more occurrences', async () => { mockFetch.mockResolvedValue({ json: async () => ({ events: [{ ...event, futureOccurrencesCount: 3 }] }) }); - const { findByText } = renderWithProviders(); + const { findByText } = await renderWithProviders(); expect(await findByText('Neste: lø. 1. aug. 2026 kl. 19:00 i Storsalen')).toBeTruthy(); expect(await findByText('Se flere tider (2)')).toBeTruthy(); @@ -87,7 +87,7 @@ it('prefixes the time with Neste: when the event has more occurrences', async () it('renders events from the API', async () => { mockFetch.mockResolvedValue({ json: async () => ({ events: [event] }) }); - const { findByText } = renderWithProviders(); + const { findByText } = await renderWithProviders(); expect(await findByText('Lørdagsquiz')).toBeTruthy(); }); @@ -95,7 +95,7 @@ it('renders events from the API', async () => { it('renders an error state when the API is unreachable', async () => { mockFetch.mockRejectedValue(new Error('network down')); - const { findByText } = renderWithProviders(); + const { findByText } = await renderWithProviders(); expect(await findByText('Kunne ikke hente programmet...')).toBeTruthy(); }); @@ -106,11 +106,11 @@ it('stops the refresh spinner and shows the error when refresh fails', async () const wrapper = ({ children }: { children: React.ReactNode }) => ( {children} ); - const { result } = renderHook(() => useEventList(), { wrapper }); + const { result } = await renderHook(() => useEventList(), { wrapper }); await waitFor(() => expect(result.current.events).toHaveLength(1)); mockFetch.mockRejectedValue(new Error('network down')); - act(() => result.current.handleRefresh()); + await act(() => result.current.handleRefresh()); await waitFor(() => expect(result.current.error).toBeTruthy()); expect(result.current.refreshing).toBe(false); diff --git a/src/modules/login/Login.test.tsx b/src/modules/login/Login.test.tsx index d308970..c02fdb9 100644 --- a/src/modules/login/Login.test.tsx +++ b/src/modules/login/Login.test.tsx @@ -15,20 +15,20 @@ jest.mock('@react-navigation/native', () => { return { ...actual, useNavigation: () => ({ goBack: mockGoBack }) }; }); -const fillForm = ( +const fillForm = async ( getByLabelText: RenderResult['getByLabelText'], { username = 'grisen@example.test', password = 'hunter2' } = {}, ) => { - fireEvent.changeText(getByLabelText('E-post / Brukernavn'), username); - fireEvent.changeText(getByLabelText('Passord'), password); + await fireEvent.changeText(getByLabelText('E-post / Brukernavn'), username); + await fireEvent.changeText(getByLabelText('Passord'), password); }; beforeEach(() => { jest.clearAllMocks(); }); -it('renders the login form', () => { - const { getByText } = renderWithProviders(); +it('renders the login form', async () => { + const { getByText } = await renderWithProviders(); expect(getByText('E-post / Brukernavn')).toBeTruthy(); expect(getByText('Passord')).toBeTruthy(); @@ -37,18 +37,18 @@ it('renders the login form', () => { }); it('moves focus to the password field when the username is submitted from the keyboard', async () => { - const { getByLabelText } = renderWithProviders(); + const { getByLabelText } = await renderWithProviders(); - fireEvent(getByLabelText('E-post / Brukernavn'), 'submitEditing'); + await fireEvent(getByLabelText('E-post / Brukernavn'), 'submitEditing'); // react-hook-form's setFocus defers the focus call to the next tick await waitFor(() => expect(focusedFieldLabel()).toBe('Passord')); }); -it('opens the forgot password page in the browser', () => { - const { getByText } = renderWithProviders(); +it('opens the forgot password page in the browser', async () => { + const { getByText } = await renderWithProviders(); - fireEvent.press(getByText('Glemt passord?')); + await fireEvent.press(getByText('Glemt passord?')); // Not one of our own domains in the test env, so no UTM tagging (covered in utils.test.ts) expect(Linking.openURL).toHaveBeenCalledWith('https://dusken.example.test/auth/password_reset/'); @@ -56,10 +56,10 @@ it('opens the forgot password page in the browser', () => { it('logs in against the API and returns to the previous screen', async () => { const login = jest.spyOn(api, 'login').mockResolvedValue({ token: 'token-123' }); - const { getByText, getByLabelText } = renderWithProviders(); + const { getByText, getByLabelText } = await renderWithProviders(); - fillForm(getByLabelText); - fireEvent.press(getByText('Logg inn')); + await fillForm(getByLabelText); + await fireEvent.press(getByText('Logg inn')); await waitFor(() => expect(mockGoBack).toHaveBeenCalled()); expect(login).toHaveBeenCalledWith('grisen@example.test', 'hunter2'); @@ -72,10 +72,10 @@ it('shows the server error when the credentials are rejected', async () => { 400, ); jest.spyOn(api, 'login').mockRejectedValueOnce(invalidCredentials); - const { getByText, getByLabelText, findByText } = renderWithProviders(); + const { getByText, getByLabelText, findByText } = await renderWithProviders(); - fillForm(getByLabelText); - fireEvent.press(getByText('Logg inn')); + await fillForm(getByLabelText); + await fireEvent.press(getByText('Logg inn')); expect(await findByText('Kan ikke logge inn med angitt brukernavn og passord')).toBeTruthy(); expect(mockGoBack).not.toHaveBeenCalled(); @@ -84,20 +84,20 @@ it('shows the server error when the credentials are rejected', async () => { it('shows a generic message when the request fails on the network', async () => { jest.spyOn(console, 'log').mockImplementation(() => {}); jest.spyOn(api, 'login').mockRejectedValueOnce(new Error('network down')); - const { getByText, getByLabelText, findByText } = renderWithProviders(); + const { getByText, getByLabelText, findByText } = await renderWithProviders(); - fillForm(getByLabelText); - fireEvent.press(getByText('Logg inn')); + await fillForm(getByLabelText); + await fireEvent.press(getByText('Logg inn')); expect(await findByText('Kunne ikke sende inn skjemaet! Prøv igjen senere...')).toBeTruthy(); expect(mockGoBack).not.toHaveBeenCalled(); }); it('logs in a dev-fake user without hitting the network', async () => { - const { getByText, getByLabelText } = renderWithProviders(); + const { getByText, getByLabelText } = await renderWithProviders(); - fillForm(getByLabelText, { username: 'member', password: 'whatever' }); - fireEvent.press(getByText('Logg inn')); + await fillForm(getByLabelText, { username: 'member', password: 'whatever' }); + await fireEvent.press(getByText('Logg inn')); await waitFor(() => expect(mockGoBack).toHaveBeenCalled()); expect(global.fetch).not.toHaveBeenCalled(); diff --git a/src/modules/login/Login.tsx b/src/modules/login/Login.tsx index 1c77722..b01d666 100644 --- a/src/modules/login/Login.tsx +++ b/src/modules/login/Login.tsx @@ -44,7 +44,9 @@ const Login = () => { Logg inn {loginError && isSubmitted && } - {isLoggingIn && } + {isLoggingIn && ( + + )} - {isDeletingUser && } + {isDeletingUser && ( + + )} ); diff --git a/src/modules/register/UserRegister.test.tsx b/src/modules/register/UserRegister.test.tsx index b286ab7..e13265f 100644 --- a/src/modules/register/UserRegister.test.tsx +++ b/src/modules/register/UserRegister.test.tsx @@ -1,6 +1,5 @@ import { act, fireEvent, waitFor, type RenderResult } from '@testing-library/react-native'; import React from 'react'; -import { ActivityIndicator } from 'react-native'; import * as api from '@/api'; import { ApiError } from '@/errors'; @@ -24,37 +23,33 @@ const validFormValues: Record = { Passord: 'hunter2', }; -const fillForm = (getByLabelText: RenderResult['getByLabelText'], overrides: Record = {}) => { - Object.entries({ ...validFormValues, ...overrides }).forEach(([label, value]) => { - fireEvent.changeText(getByLabelText(label), value); - }); +const fillForm = async (getByLabelText: RenderResult['getByLabelText'], overrides: Record = {}) => { + for (const [label, value] of Object.entries({ ...validFormValues, ...overrides })) { + await fireEvent.changeText(getByLabelText(label), value); + } }; beforeEach(() => { jest.clearAllMocks(); }); -afterEach(() => { - jest.useRealTimers(); -}); - -it('renders the registration form', () => { - const { getByText } = renderWithProviders(); +it('renders the registration form', async () => { + const { getByText } = await renderWithProviders(); expect(getByText('Fornavn')).toBeTruthy(); expect(getByText('E-post')).toBeTruthy(); expect(getByText('Registrer meg')).toBeTruthy(); }); -it('disables the submit button while the form is incomplete', () => { +it('disables the submit button while the form is incomplete', async () => { const registerUser = jest.spyOn(api, 'registerUser'); - const { getByText, getByRole, UNSAFE_queryByType } = renderWithProviders(); + const { getByText, getByRole, queryByRole } = await renderWithProviders(); expect(getByRole('button', { name: 'Registrer meg' })).toBeDisabled(); - fireEvent.press(getByText('Registrer meg')); + await fireEvent.press(getByText('Registrer meg')); expect(registerUser).not.toHaveBeenCalled(); - expect(UNSAFE_queryByType(ActivityIndicator)).toBeNull(); + expect(queryByRole('progressbar')).toBeNull(); }); it('submits the form, shows a spinner while pending and navigates back on success', async () => { @@ -62,14 +57,14 @@ it('submits the form, shows a spinner while pending and navigates back on succes const registerUser = jest .spyOn(api, 'registerUser') .mockImplementationOnce(() => new Promise((resolve) => (resolveRegister = resolve))); - const { getByText, getByRole, getByLabelText, UNSAFE_getByType } = renderWithProviders(); + const { getByText, getByRole, getByLabelText } = await renderWithProviders(); - fillForm(getByLabelText); + await fillForm(getByLabelText); await waitFor(() => expect(getByRole('button', { name: 'Registrer meg' })).toBeEnabled()); - fireEvent.press(getByText('Registrer meg')); + await fireEvent.press(getByText('Registrer meg')); // the spinner shows while the request is in flight - await waitFor(() => expect(UNSAFE_getByType(ActivityIndicator)).toBeTruthy()); + await waitFor(() => expect(getByRole('progressbar')).toBeTruthy()); expect(registerUser).toHaveBeenCalledWith( expect.objectContaining({ first_name: 'Grisen', @@ -92,22 +87,22 @@ it('shows server field errors on the matching form field', async () => { jest .spyOn(api, 'registerUser') .mockRejectedValueOnce(new ApiError({ email: ['Denne e-postadressen er allerede i bruk'] }, 400)); - const { getByText, getByRole, getByLabelText, findByText } = renderWithProviders(); + const { getByText, getByRole, getByLabelText, findByText } = await renderWithProviders(); - fillForm(getByLabelText); + await fillForm(getByLabelText); await waitFor(() => expect(getByRole('button', { name: 'Registrer meg' })).toBeEnabled()); - fireEvent.press(getByText('Registrer meg')); + await fireEvent.press(getByText('Registrer meg')); expect(await findByText('Denne e-postadressen er allerede i bruk')).toBeTruthy(); expect(mockGoBack).not.toHaveBeenCalled(); }); it('moves focus down the form when a field is submitted from the keyboard', async () => { - const { getByLabelText } = renderWithProviders(); + const { getByLabelText } = await renderWithProviders(); const labels = ['Fornavn', 'Etternavn', 'E-post', 'Mobilnummer', 'Passord']; for (const [i, label] of labels.slice(0, -1).entries()) { - fireEvent(getByLabelText(label), 'submitEditing'); + await fireEvent(getByLabelText(label), 'submitEditing'); // react-hook-form's setFocus defers the focus call to the next tick await waitFor(() => expect(focusedFieldLabel()).toBe(labels[i + 1])); } @@ -115,41 +110,23 @@ it('moves focus down the form when a field is submitted from the keyboard', asyn it('shows a generic error when registration fails on the network', async () => { jest.spyOn(api, 'registerUser').mockRejectedValueOnce(new Error('network down')); - const { getByText, getByRole, getByLabelText, findByText } = renderWithProviders(); + const { getByText, getByRole, getByLabelText, findByText } = await renderWithProviders(); - fillForm(getByLabelText); + await fillForm(getByLabelText); await waitFor(() => expect(getByRole('button', { name: 'Registrer meg' })).toBeEnabled()); - fireEvent.press(getByText('Registrer meg')); + await fireEvent.press(getByText('Registrer meg')); expect(await findByText('Kunne ikke sende inn skjemaet! Prøv igjen senere...')).toBeTruthy(); expect(mockGoBack).not.toHaveBeenCalled(); }); -it('warns instead of submitting when the keyboard submits before validation has caught up', async () => { - jest.useFakeTimers(); - const registerUser = jest.spyOn(api, 'registerUser'); - const { getByLabelText, findByText, queryByText } = renderWithProviders(); - - // Filling in and submitting in the same tick reaches the submit handler while isValid still reflects the empty form - fillForm(getByLabelText); - fireEvent(getByLabelText('Passord'), 'submitEditing'); - - const message = 'Noen av feltene er ikke fylt ut riktig'; - expect(await findByText(message)).toBeTruthy(); - expect(registerUser).not.toHaveBeenCalled(); - - // The toast dismisses itself after five seconds - act(() => jest.advanceTimersByTime(5000)); - expect(queryByText(message)).toBeNull(); -}); - it('rejects an invalid e-mail address without submitting', async () => { const registerUser = jest.spyOn(api, 'registerUser'); - const { getByLabelText, findByText } = renderWithProviders(); + const { getByLabelText, findByText } = await renderWithProviders(); - fillForm(getByLabelText, { 'E-post': 'not-an-email' }); + await fillForm(getByLabelText, { 'E-post': 'not-an-email' }); // Enter on the last field submits too, bypassing the disabled button - fireEvent(getByLabelText('Passord'), 'submitEditing'); + await fireEvent(getByLabelText('Passord'), 'submitEditing'); expect(await findByText('er ikke en gyldig e-post')).toBeTruthy(); expect(registerUser).not.toHaveBeenCalled(); diff --git a/src/modules/register/UserRegister.tsx b/src/modules/register/UserRegister.tsx index 3978065..584f218 100644 --- a/src/modules/register/UserRegister.tsx +++ b/src/modules/register/UserRegister.tsx @@ -6,24 +6,13 @@ import Button from '@/components/Button'; import FormErrors from '@/components/FormErrors'; import FormInput from '@/components/FormInput'; import PrivacyButton from '@/components/PrivacyButton'; -import Toast from '@/components/Toast'; import theme from '@/theme'; import useUserRegister from './useUserRegister'; const UserRegister = () => { - const { - isRegisteringUser, - onSubmit, - isValid, - isSubmitted, - control, - errors, - registerError, - setFocus, - toast, - setToast, - } = useUserRegister(); + const { isRegisteringUser, onSubmit, isValid, isSubmitted, control, errors, registerError, setFocus } = + useUserRegister(); const isDisabled = isRegisteringUser || !isValid; return ( @@ -78,21 +67,14 @@ const UserRegister = () => { onSubmitEditing={onSubmit} /> {registerError && isSubmitted && } - {isRegisteringUser && } + {isRegisteringUser && ( + + )} - { - setToast(null); - }} - /> ); }; diff --git a/src/modules/register/useUserRegister.tsx b/src/modules/register/useUserRegister.tsx index 78fbf0e..ad262c5 100644 --- a/src/modules/register/useUserRegister.tsx +++ b/src/modules/register/useUserRegister.tsx @@ -1,18 +1,15 @@ import { useNavigation } from '@react-navigation/native'; -import { useState } from 'react'; import { useForm } from 'react-hook-form'; import { ApiError } from '@/errors'; import { useRegisterUserMutation } from '@/queries/useRegisterUserMutation'; import type { UserRegisterFormValues } from '@/types/forms'; -import { ToastProps } from '@/types/toast'; export type { UserRegisterFormValues }; const useUserRegister = () => { const navigation = useNavigation(); const { mutate: register, isPending, error } = useRegisterUserMutation(); - const [toast, setToast] = useState(null); const { control, @@ -25,16 +22,6 @@ const useUserRegister = () => { const onSubmit = handleSubmit((values) => { clearErrors(); - if (!isValid) { - setToast({ - message: 'Noen av feltene er ikke fylt ut riktig', - visible: true, - preset: 'failure', - }); - return; - } - setToast(null); - register(values, { onSuccess: () => navigation.goBack(), onError: (registerError) => { @@ -60,8 +47,6 @@ const useUserRegister = () => { control, errors, setFocus, - toast, - setToast, }; }; export default useUserRegister; diff --git a/src/testUtils.tsx b/src/testUtils.tsx index 21d3c3c..3b7c449 100644 --- a/src/testUtils.tsx +++ b/src/testUtils.tsx @@ -48,7 +48,7 @@ interface RenderWithProvidersOptions { * Renders a screen inside the providers it expects in the real app: a fresh * query client (no persister), AuthProvider and a NavigationContainer. */ -export function renderWithProviders( +export async function renderWithProviders( ui: ReactElement, { initialToken = null, @@ -71,7 +71,7 @@ export function renderWithProviders( queryClient.setQueryData(queryKeys.membershipTypes, initialMembershipTypes); } - const result = render( + const result = await render( {ui}