diff --git a/examples/basic/.expo-shared/assets.json b/examples/basic/.expo-shared/assets.json new file mode 100644 index 000000000..1e6decfbb --- /dev/null +++ b/examples/basic/.expo-shared/assets.json @@ -0,0 +1,4 @@ +{ + "12bb71342c6255bbf50437ec8f4441c083f47cdb74bd89160c15e4f43e52a1cb": true, + "40b842e832070c58deac6aa9e08fa459302ee3f9da492c7e77d93d2fbf4a56fd": true +} diff --git a/examples/basic/.gitignore b/examples/basic/.gitignore new file mode 100644 index 000000000..ec8a36a25 --- /dev/null +++ b/examples/basic/.gitignore @@ -0,0 +1,14 @@ +node_modules/ +.expo/ +dist/ +npm-debug.* +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision +*.orig.* +web-build/ + +# macOS +.DS_Store diff --git a/examples/basic/App.tsx b/examples/basic/App.tsx new file mode 100644 index 000000000..8a265cfbf --- /dev/null +++ b/examples/basic/App.tsx @@ -0,0 +1,20 @@ +import * as React from 'react'; +import { SafeAreaView } from 'react-native'; +import { LoginForm } from './components/LoginForm'; +import { Home } from './components/Home'; + +const App = () => { + const [user, setUser] = React.useState(null); + + return ( + + {user == null ? ( + + ) : ( + + )} + + ); +}; + +export default App; diff --git a/examples/basic/README.md b/examples/basic/README.md new file mode 100644 index 000000000..0d1ba09a8 --- /dev/null +++ b/examples/basic/README.md @@ -0,0 +1,9 @@ +# Basic RNTL setup + +This example is shows a basic modern React Native Testing Library setup in a template Expo app. + +The app and related tests written in TypeScript, and it uses recommended practices like: + +- testing large pieces of application instead of small components +- using `screen`-based queries +- using recommended query types, e.g. `byText`, `byLabelText`, `byPlaceholderText` over `byTestId` diff --git a/examples/basic/__tests__/App.test.tsx b/examples/basic/__tests__/App.test.tsx new file mode 100644 index 000000000..671fb2071 --- /dev/null +++ b/examples/basic/__tests__/App.test.tsx @@ -0,0 +1,110 @@ +import * as React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react-native'; +import App from '../App'; + +/** + * A good place to start is having a tests that your component renders correctly. + */ +test('renders correctly', () => { + // Idiom: no need to capture render output, as we will use `screen` for queries. + render(); + + // Idiom: `getByXxx` is a predicate by itself, but we will use it with `expect().toBeTruthy()` + // to clarify our intent. + expect(screen.getByText('Sign in to Example App')).toBeTruthy(); +}); + +/** + * Hint: It's best when your tests are similar to what a manual test scenarions would look like, + * i.e. a series of actions taken by the user, followed by a series of assertions verified from + * his point of view. + */ +test('User can sign in successully with correct credentials', async () => { + // Idiom: no need to capture render output, as we will use `screen` for queries. + render(); + + // Idiom: `getByXxx` is a predicate by itself, but we will use it with `expect().toBeTruthy()` to + // clarify our intent. + // Note: `.toBeTruthy()` is the preferred matcher for checking that elements are present. + expect(screen.getByText('Sign in to Example App')).toBeTruthy(); + expect(screen.getByText('Username')).toBeTruthy(); + expect(screen.getByText('Password')).toBeTruthy(); + + // Hint: we can use `getByLabelText` to find our text inputs in accessibility-friendly way. + fireEvent.changeText(screen.getByLabelText('Username'), 'admin'); + fireEvent.changeText(screen.getByLabelText('Password'), 'admin1'); + + // Hint: we can use `getByText` to find our button by its text. + fireEvent.press(screen.getByText('Sign In')); + + // Idiom: since pressing button triggers async operation we need to use `findBy` query to wait + // for the action to complete. + // Hint: subsequent queries do not need to use `findBy`, because they are used after the async action + // already finished + expect(await screen.findByText('Welcome admin!')).toBeTruthy(); + + // Idiom: use `queryByXxx` with `expect().toBeFalsy()` to assess that element is not present. + expect(screen.queryByText('Sign in to Example App')).toBeFalsy(); + expect(screen.queryByText('Username')).toBeFalsy(); + expect(screen.queryByText('Password')).toBeFalsy(); +}); + +/** + * Another test case based on manual test scenario. + * + * Hint: Try to tests what a user would see and do, instead of assering internal component state + * that is not directly reflected in the UI. + * + * For this reason prefer quries that correspond to things directly observable by the user like: + * `getByText`, `getByLabelText`, `getByPlaceholderText, `getByDisplayValue`, `getByRole`, etc. + * over `getByTestId` which is not directly observable by the user. + * + * Note: that some times you will have to resort to `getByTestId`, but treat it as a last resort. + */ +test('User will see errors for incorrect credentials', async () => { + render(); + + expect(screen.getByText('Sign in to Example App')).toBeTruthy(); + expect(screen.getByText('Username')).toBeTruthy(); + expect(screen.getByText('Password')).toBeTruthy(); + + fireEvent.changeText(screen.getByLabelText('Username'), 'admin'); + fireEvent.changeText(screen.getByLabelText('Password'), 'qwerty123'); + fireEvent.press(screen.getByText('Sign In')); + + // Hint: you can use custom Jest Native matcher to check text content. + expect(await screen.findByLabelText('Error')).toHaveTextContent( + 'Incorrect username or password' + ); + + expect(screen.getByText('Sign in to Example App')).toBeTruthy(); + expect(screen.getByText('Username')).toBeTruthy(); + expect(screen.getByText('Password')).toBeTruthy(); +}); + +/** + * Do not be afraid to write longer test scenarios, with repeating act and assert statements. + */ +test('User can sign in after incorrect attempt', async () => { + render(); + + expect(screen.getByText('Sign in to Example App')).toBeTruthy(); + expect(screen.getByText('Username')).toBeTruthy(); + expect(screen.getByText('Password')).toBeTruthy(); + + fireEvent.changeText(screen.getByLabelText('Username'), 'admin'); + fireEvent.changeText(screen.getByLabelText('Password'), 'qwerty123'); + fireEvent.press(screen.getByText('Sign In')); + + expect(await screen.findByLabelText('Error')).toHaveTextContent( + 'Incorrect username or password' + ); + + fireEvent.changeText(screen.getByLabelText('Password'), 'admin1'); + fireEvent.press(screen.getByText('Sign In')); + + expect(await screen.findByText('Welcome admin!')).toBeTruthy(); + expect(screen.queryByText('Sign in to Example App')).toBeFalsy(); + expect(screen.queryByText('Username')).toBeFalsy(); + expect(screen.queryByText('Password')).toBeFalsy(); +}); diff --git a/examples/basic/app.json b/examples/basic/app.json new file mode 100644 index 000000000..6115577c8 --- /dev/null +++ b/examples/basic/app.json @@ -0,0 +1,31 @@ +{ + "expo": { + "name": "RNTL Example Basic", + "slug": "rntl-example-basic", + "version": "1.0.0", + "orientation": "portrait", + "icon": "./assets/icon.png", + "userInterfaceStyle": "light", + "splash": { + "image": "./assets/splash.png", + "resizeMode": "contain", + "backgroundColor": "#ffffff" + }, + "updates": { + "fallbackToCacheTimeout": 0 + }, + "assetBundlePatterns": ["**/*"], + "ios": { + "supportsTablet": true + }, + "android": { + "adaptiveIcon": { + "foregroundImage": "./assets/adaptive-icon.png", + "backgroundColor": "#FFFFFF" + } + }, + "web": { + "favicon": "./assets/favicon.png" + } + } +} diff --git a/examples/basic/assets/adaptive-icon.png b/examples/basic/assets/adaptive-icon.png new file mode 100644 index 000000000..03d6f6b6c Binary files /dev/null and b/examples/basic/assets/adaptive-icon.png differ diff --git a/examples/basic/assets/favicon.png b/examples/basic/assets/favicon.png new file mode 100644 index 000000000..e75f697b1 Binary files /dev/null and b/examples/basic/assets/favicon.png differ diff --git a/examples/basic/assets/icon.png b/examples/basic/assets/icon.png new file mode 100644 index 000000000..a0b1526fc Binary files /dev/null and b/examples/basic/assets/icon.png differ diff --git a/examples/basic/assets/splash.png b/examples/basic/assets/splash.png new file mode 100644 index 000000000..0e89705a9 Binary files /dev/null and b/examples/basic/assets/splash.png differ diff --git a/examples/basic/babel.config.js b/examples/basic/babel.config.js new file mode 100644 index 000000000..9d89e1311 --- /dev/null +++ b/examples/basic/babel.config.js @@ -0,0 +1,6 @@ +module.exports = function (api) { + api.cache(true); + return { + presets: ['babel-preset-expo'], + }; +}; diff --git a/examples/basic/components/Home.tsx b/examples/basic/components/Home.tsx new file mode 100644 index 000000000..74317d50d --- /dev/null +++ b/examples/basic/components/Home.tsx @@ -0,0 +1,26 @@ +import * as React from 'react'; +import { StyleSheet, View, Text } from 'react-native'; + +type Props = { + user: string; +}; + +export function Home({ user }: Props) { + return ( + + Welcome {user}! + + ); +} + +const styles = StyleSheet.create({ + container: { + padding: 20, + }, + title: { + alignSelf: 'center', + fontSize: 24, + marginTop: 8, + marginBottom: 40, + }, +}); diff --git a/examples/basic/components/LoginForm.tsx b/examples/basic/components/LoginForm.tsx new file mode 100644 index 000000000..dc3961e48 --- /dev/null +++ b/examples/basic/components/LoginForm.tsx @@ -0,0 +1,131 @@ +import * as React from 'react'; +import { + StyleSheet, + View, + Text, + TextInput, + TouchableOpacity, + ActivityIndicator, +} from 'react-native'; + +type Props = { + onLoginSuccess: (user: string) => void; +}; + +export function LoginForm({ onLoginSuccess }: Props) { + const [username, setUsername] = React.useState(''); + const [password, setPassword] = React.useState(''); + const [error, setError] = React.useState(); + const [isLoading, setIsLoading] = React.useState(false); + + const handleSignIn = async () => { + setIsLoading(true); + + const user = await authUser(username, password); + setIsLoading(false); + + if (user) { + setError(undefined); + onLoginSuccess(user); + } else { + setError('Incorrect username or password'); + } + }; + + return ( + + Sign in to Example App + + Username + + + Password + + + {error && ( + + {error} + + )} + + + {isLoading ? ( + + ) : ( + Sign In + )} + + + ); +} + +/** + * Fake authentication function according to our abilities. + * @param username The username to authenticate. + * @param password The password to authenticate. + * @returns username if the username and password are correct, null otherwise. + */ +async function authUser( + username: string, + password: string +): Promise { + return new Promise((resolve) => + setTimeout(() => { + const hasValidCredentials = username === 'admin' && password === 'admin1'; + resolve(hasValidCredentials ? username : null); + }, 250) + ); +} + +const styles = StyleSheet.create({ + container: { + padding: 20, + }, + title: { + alignSelf: 'center', + fontSize: 24, + marginTop: 8, + marginBottom: 40, + }, + textLabel: { + fontSize: 16, + color: '#444', + }, + textInput: { + fontSize: 20, + padding: 8, + marginVertical: 8, + borderColor: 'black', + borderWidth: 1, + }, + button: { + backgroundColor: '#3256a8', + padding: 16, + alignItems: 'center', + justifyContent: 'center', + marginTop: 20, + minHeight: 56, + }, + buttonText: { + fontSize: 20, + fontWeight: '600', + color: 'white', + }, + validator: { + color: 'red', + fontSize: 18, + marginTop: 8, + }, +}); diff --git a/examples/basic/jest-setup.js b/examples/basic/jest-setup.js new file mode 100644 index 000000000..af6ed0772 --- /dev/null +++ b/examples/basic/jest-setup.js @@ -0,0 +1,7 @@ +/* eslint-disable no-undef, import/no-extraneous-dependencies */ + +// Import Jest Native matchers +import '@testing-library/jest-native/extend-expect'; + +// Silence the warning: Animated: `useNativeDriver` is not supported because the native animated module is missing +jest.mock('react-native/Libraries/Animated/NativeAnimatedHelper'); diff --git a/examples/basic/jest.config.js b/examples/basic/jest.config.js new file mode 100644 index 000000000..101722eb7 --- /dev/null +++ b/examples/basic/jest.config.js @@ -0,0 +1,5 @@ +module.exports = { + preset: '@testing-library/react-native', + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], + setupFilesAfterEnv: ['./jest-setup.js'], +}; diff --git a/examples/basic/package.json b/examples/basic/package.json new file mode 100644 index 000000000..6a6a354b0 --- /dev/null +++ b/examples/basic/package.json @@ -0,0 +1,30 @@ +{ + "main": "node_modules/expo/AppEntry.js", + "scripts": { + "start": "expo start", + "android": "expo start --android", + "ios": "expo start --ios", + "web": "expo start --web", + "eject": "expo eject", + "test": "jest" + }, + "dependencies": { + "expo": "~45.0.0", + "expo-status-bar": "~1.3.0", + "react": "17.0.2", + "react-dom": "17.0.2", + "react-native": "0.68.2", + "react-native-web": "0.17.7" + }, + "devDependencies": { + "@babel/core": "^7.12.9", + "@testing-library/jest-native": "^4.0.5", + "@testing-library/react-native": "^11.0.0", + "@types/react": "~17.0.21", + "@types/react-native": "~0.67.6", + "jest": "^28.0.0", + "react-test-renderer": "^17.0.2", + "typescript": "~4.3.5" + }, + "private": true +} diff --git a/examples/basic/tsconfig.json b/examples/basic/tsconfig.json new file mode 100644 index 000000000..b9567f605 --- /dev/null +++ b/examples/basic/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "expo/tsconfig.base", + "compilerOptions": { + "strict": true + } +}