├── .eslintignore ├── ui ├── jest.config.js ├── static │ └── favicon.ico ├── components │ ├── SectionList │ │ ├── img │ │ │ └── thinkin.png │ │ ├── ToggleShow.js │ │ ├── ToggleButton.js │ │ ├── fixtures.js │ │ └── index.js │ ├── File │ │ ├── Code │ │ │ ├── __fixtures__ │ │ │ │ └── xhr.js │ │ │ ├── index.js │ │ │ ├── style.js │ │ │ └── shared.js │ │ ├── shared.js │ │ ├── FileHeader.js │ │ ├── index.js │ │ └── FileActions.js │ ├── Section │ │ ├── __fixtures__ │ │ │ └── index.js │ │ ├── TitleLink.js │ │ ├── Readme.js │ │ └── index.js │ ├── Header │ │ ├── AboutButton.js │ │ ├── GithubLink.js │ │ ├── styles.js │ │ ├── Checkbox.js │ │ ├── TestKindSelect.js │ │ ├── SearchBox.js │ │ └── index.js │ ├── shared │ │ ├── WindowKeyListener.js │ │ ├── SectionLink.js │ │ ├── FuzzyHighlighter.js │ │ └── styles.js │ ├── Footer.js │ ├── App.fixture.js │ ├── AboutModal.js │ └── App.js ├── svg │ ├── triangle-down.svg │ ├── chevron-left.svg │ ├── link-external.svg │ ├── settings.svg │ ├── question.svg │ ├── clippy.svg │ ├── link.svg │ ├── info.svg │ └── mark-github.svg ├── cosmos.proxies.js ├── global.js ├── cosmos.config.js ├── pages │ ├── about.js │ ├── _app.js │ ├── _document.js │ └── index.js ├── contexts.js ├── import-files.js ├── shared │ ├── testKinds.js │ └── section.js ├── webpack-loaders │ ├── import-tests-loader.test.js │ ├── readme-text-loader.test.js │ ├── readme-text-loader.js │ └── import-tests-loader.js ├── types.js ├── server │ ├── testFiles.js │ └── start-dev.js ├── cosmos-proxies │ ├── globalStyle.js │ └── next.js ├── global-style.js ├── next.config.js ├── search.js └── webpack.extend.js ├── .prettierrc ├── tests ├── jest-interactor │ ├── jest.config.js │ ├── render-text │ │ ├── README.md │ │ └── test.js │ ├── click-callback │ │ ├── README.md │ │ └── test.js │ ├── redux │ │ ├── reducer.js │ │ ├── README.md │ │ └── test.js │ ├── order.js │ ├── counter-interactor.js │ ├── styled-components │ │ ├── README.md │ │ └── test.js │ ├── local-state │ │ ├── README.md │ │ └── test.js │ ├── localstorage │ │ ├── README.md │ │ └── test.js │ ├── xhr │ │ ├── README.md │ │ └── test.js │ ├── fetch │ │ ├── README.md │ │ └── test.js │ ├── react-router │ │ ├── README.md │ │ └── test.js │ └── README.md ├── jest-enzyme │ ├── jest.config.js │ ├── render-text │ │ ├── README.md │ │ └── test.js │ ├── README.md │ ├── click-callback │ │ ├── README.md │ │ └── test.js │ ├── redux │ │ ├── reducer.js │ │ ├── README.md │ │ └── test.js │ ├── enzyme.setup.js │ ├── order.js │ ├── styled-components │ │ ├── README.md │ │ └── test.js │ ├── local-state │ │ ├── README.md │ │ └── test.js │ ├── localstorage │ │ ├── README.md │ │ └── test.js │ ├── react-router │ │ ├── README.md │ │ └── test.js │ ├── xhr │ │ ├── README.md │ │ └── test.js │ └── fetch │ │ ├── README.md │ │ └── test.js ├── jest-rtl │ ├── jest.config.js │ ├── rtl.setup.js │ ├── render-text │ │ ├── README.md │ │ └── test.js │ ├── click-callback │ │ ├── README.md │ │ └── test.js │ ├── redux │ │ ├── reducer.js │ │ ├── README.md │ │ └── test.js │ ├── README.md │ ├── order.js │ ├── styled-components │ │ ├── README.md │ │ └── test.js │ ├── local-state │ │ ├── README.md │ │ └── test.js │ ├── localstorage │ │ ├── README.md │ │ └── test.js │ ├── xhr │ │ ├── README.md │ │ └── test.js │ ├── fetch │ │ ├── README.md │ │ └── test.js │ └── react-router │ │ ├── README.md │ │ └── test.js └── shared │ ├── components │ ├── HelloMessage.js │ ├── Button.js │ ├── HelloMessageStyled.js │ ├── UserWithRouter.js │ ├── StatefulCounter.js │ ├── ReduxCounter.js │ ├── ServerXhrCounter.js │ ├── PersistentForm.js │ └── ServerFetchCounter.js │ └── theme.js ├── .gitignore ├── .stubs ├── component.js ├── raw.js └── readme-text.js ├── screenshot2.png ├── .flowconfig ├── CREDITS.md ├── README.md ├── .babelrc.js ├── LICENSE ├── .eslintrc ├── CONTRIBUTING.md ├── .circleci └── config.yml ├── WHATSTHIS.md ├── TODO.md ├── package.json └── flow-typed └── npm ├── express_v4.16.x.js └── jest_v23.x.x.js /.eslintignore: -------------------------------------------------------------------------------- 1 | flow-typed 2 | -------------------------------------------------------------------------------- /ui/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } 4 | -------------------------------------------------------------------------------- /tests/jest-interactor/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | yarn-error.log 3 | .next 4 | .export 5 | -------------------------------------------------------------------------------- /.stubs/component.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | export default () => null; 4 | -------------------------------------------------------------------------------- /.stubs/raw.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | module.exports = 'raw file contents'; 4 | -------------------------------------------------------------------------------- /.stubs/readme-text.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | module.exports = { title: 'Title', body: [] }; 4 | -------------------------------------------------------------------------------- /screenshot2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ovidiuch/react-testing-examples/HEAD/screenshot2.png -------------------------------------------------------------------------------- /tests/jest-enzyme/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | setupFiles: ['./enzyme.setup.js'] 3 | }; 4 | -------------------------------------------------------------------------------- /tests/jest-rtl/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | setupFilesAfterEnv: ['./rtl.setup.js'] 3 | }; 4 | -------------------------------------------------------------------------------- /ui/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ovidiuch/react-testing-examples/HEAD/ui/static/favicon.ico -------------------------------------------------------------------------------- /ui/components/SectionList/img/thinkin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ovidiuch/react-testing-examples/HEAD/ui/components/SectionList/img/thinkin.png -------------------------------------------------------------------------------- /tests/shared/components/HelloMessage.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export function HelloMessage({ name }) { 4 | return Hello {name}; 5 | } 6 | -------------------------------------------------------------------------------- /tests/shared/components/Button.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export function Button({ onClick }) { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /ui/svg/triangle-down.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /tests/jest-rtl/rtl.setup.js: -------------------------------------------------------------------------------- 1 | // See https://github.com/kentcdodds/react-testing-library#global-config 2 | import 'jest-dom/extend-expect'; 3 | import 'react-testing-library/cleanup-after-each'; 4 | -------------------------------------------------------------------------------- /tests/shared/theme.js: -------------------------------------------------------------------------------- 1 | export const themeLight = { 2 | background: 'white', 3 | text: 'black' 4 | }; 5 | 6 | export const themeDark = { 7 | background: 'black', 8 | text: 'white' 9 | }; 10 | -------------------------------------------------------------------------------- /ui/svg/chevron-left.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /tests/jest-enzyme/render-text/README.md: -------------------------------------------------------------------------------- 1 | ## Text with prop value is rendered 2 | 3 | The component renders variable text based on a string prop. We test that the component renders the value of the passed prop. 4 | -------------------------------------------------------------------------------- /tests/jest-rtl/render-text/README.md: -------------------------------------------------------------------------------- 1 | ## Text with prop value is rendered 2 | 3 | The component renders variable text based on a string prop. We test that the component renders the value of the passed prop. 4 | -------------------------------------------------------------------------------- /ui/cosmos.proxies.js: -------------------------------------------------------------------------------- 1 | import { NextRouterProxy } from './cosmos-proxies/next'; 2 | import { GlobalStyleProxy } from './cosmos-proxies/globalStyle'; 3 | 4 | export default [NextRouterProxy, GlobalStyleProxy]; 5 | -------------------------------------------------------------------------------- /tests/jest-interactor/render-text/README.md: -------------------------------------------------------------------------------- 1 | ## Text with prop value is rendered 2 | 3 | The component renders variable text based on a string prop. We test that the component renders the value of the passed prop. 4 | -------------------------------------------------------------------------------- /tests/jest-rtl/click-callback/README.md: -------------------------------------------------------------------------------- 1 | ## Callback fires on button click 2 | 3 | The component receives a callback prop and renders a button. We test that the callback prop is called when the button is clicked. 4 | -------------------------------------------------------------------------------- /tests/jest-enzyme/README.md: -------------------------------------------------------------------------------- 1 | ## Setup 2 | 3 | Minimal setup required to use [Enzyme](https://airbnb.io/enzyme/) with [Jest](https://jestjs.io/). 4 | 5 | > All examples featured here run using these exact config files. 6 | -------------------------------------------------------------------------------- /tests/jest-enzyme/click-callback/README.md: -------------------------------------------------------------------------------- 1 | ## Callback fires on button click 2 | 3 | The component receives a callback prop and renders a button. We test that the callback prop is called when the button is clicked. 4 | -------------------------------------------------------------------------------- /tests/jest-interactor/click-callback/README.md: -------------------------------------------------------------------------------- 1 | ## Callback fires on button click 2 | 3 | The component receives a callback prop and renders a button. We test that the callback prop is called when the button is clicked. 4 | -------------------------------------------------------------------------------- /ui/global.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | // Importing 'prismjs/components/prism-jsx' loads the jsx Prism language, but 4 | // it only works if we import 'prismjs' first 5 | import 'prismjs'; 6 | import 'prismjs/components/prism-jsx'; 7 | -------------------------------------------------------------------------------- /ui/cosmos.config.js: -------------------------------------------------------------------------------- 1 | const { addLoaders } = require('./webpack.extend'); 2 | 3 | module.exports = { 4 | globalImports: ['./global'], 5 | webpack: config => { 6 | return addLoaders(config, 'babel-loader'); 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /tests/jest-rtl/redux/reducer.js: -------------------------------------------------------------------------------- 1 | export function counterReducer(state = { count: 0 }, { type }) { 2 | switch (type) { 3 | case 'INCREMENT': 4 | return { ...state, count: state.count + 1 }; 5 | default: 6 | return state; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tests/jest-enzyme/redux/reducer.js: -------------------------------------------------------------------------------- 1 | export function counterReducer(state = { count: 0 }, { type }) { 2 | switch (type) { 3 | case 'INCREMENT': 4 | return { ...state, count: state.count + 1 }; 5 | default: 6 | return state; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tests/jest-rtl/README.md: -------------------------------------------------------------------------------- 1 | ## Setup 2 | 3 | Minimal setup required to use [react-testing-library](https://github.com/kentcdodds/react-testing-library) with [Jest](https://jestjs.io/). 4 | 5 | > All examples featured here run using these exact config files. 6 | -------------------------------------------------------------------------------- /tests/jest-interactor/redux/reducer.js: -------------------------------------------------------------------------------- 1 | export function counterReducer(state = { count: 0 }, { type }) { 2 | switch (type) { 3 | case 'INCREMENT': 4 | return { ...state, count: state.count + 1 }; 5 | default: 6 | return state; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /ui/svg/link-external.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /ui/pages/about.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import React from 'react'; 4 | import { App } from '../components/App'; 5 | import { gitRef } from '../import-files'; 6 | import { getTestKind } from '../shared/testKinds'; 7 | 8 | export default () => ; 9 | -------------------------------------------------------------------------------- /tests/jest-enzyme/enzyme.setup.js: -------------------------------------------------------------------------------- 1 | import { configure } from 'enzyme'; 2 | import React16Adapter from 'enzyme-adapter-react-16'; 3 | 4 | // Enzyme requires a React version-specific adapter 5 | // See https://github.com/airbnb/enzyme/tree/master/packages 6 | configure({ adapter: new React16Adapter() }); 7 | -------------------------------------------------------------------------------- /tests/jest-rtl/order.js: -------------------------------------------------------------------------------- 1 | // This list is only required for enforcing sorting order. New tests will show 2 | // up in the UI even if they aren't added here. 3 | export default [ 4 | 'render-text', 5 | 'click-callback', 6 | 'local-state', 7 | 'redux', 8 | 'react-router', 9 | 'fetch', 10 | 'xhr', 11 | 'localstorage' 12 | ]; 13 | -------------------------------------------------------------------------------- /tests/jest-enzyme/order.js: -------------------------------------------------------------------------------- 1 | // This list is only required for enforcing sorting order. New tests will show 2 | // up in the UI even if they aren't added here. 3 | export default [ 4 | 'render-text', 5 | 'click-callback', 6 | 'local-state', 7 | 'redux', 8 | 'react-router', 9 | 'fetch', 10 | 'xhr', 11 | 'localstorage' 12 | ]; 13 | -------------------------------------------------------------------------------- /tests/jest-interactor/order.js: -------------------------------------------------------------------------------- 1 | // This list is only required for enforcing sorting order. New tests will show 2 | // up in the UI even if they aren't added here. 3 | export default [ 4 | 'render-text', 5 | 'click-callback', 6 | 'local-state', 7 | 'redux', 8 | 'react-router', 9 | 'fetch', 10 | 'xhr', 11 | 'localstorage' 12 | ]; 13 | -------------------------------------------------------------------------------- /tests/jest-interactor/counter-interactor.js: -------------------------------------------------------------------------------- 1 | import { interactor, text, clickable } from '@bigtest/interactor'; 2 | 3 | // A reusable interactor for the xhr, fetch, and redux counter tests 4 | @interactor class CounterInteractor { 5 | clickedText = text(); 6 | increment = clickable('button'); 7 | } 8 | 9 | export default CounterInteractor; 10 | -------------------------------------------------------------------------------- /tests/jest-rtl/redux/README.md: -------------------------------------------------------------------------------- 1 | ## Redux state and action 2 | 3 | The component reads and updates a counter from the [Redux](https://redux.js.org/) store. 4 | 5 | We test that the component renders the counter value. Then we click on the increment button, which updates the Redux state, and afterwards test that the component renders the incremented value. 6 | -------------------------------------------------------------------------------- /tests/shared/components/HelloMessageStyled.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const HelloMessageStyled = ({ name }) => ( 4 | Hello {name} 5 | ); 6 | 7 | export const Container = styled.span` 8 | background: ${props => props.theme.background}; 9 | color: ${props => props.theme.text}; 10 | `; 11 | -------------------------------------------------------------------------------- /tests/jest-enzyme/redux/README.md: -------------------------------------------------------------------------------- 1 | ## Redux state and action 2 | 3 | The component reads and updates a counter from the [Redux](https://redux.js.org/) store. 4 | 5 | We test that the component renders the counter value. Then we click on the increment button, which updates the Redux state, and afterwards test that the component renders the incremented value. 6 | -------------------------------------------------------------------------------- /tests/jest-interactor/redux/README.md: -------------------------------------------------------------------------------- 1 | ## Redux state and action 2 | 3 | The component reads and updates a counter from the [Redux](https://redux.js.org/) store. 4 | 5 | We test that the component renders the counter value. Then we click on the increment button, which updates the Redux state, and afterwards test that the component renders the incremented value. 6 | -------------------------------------------------------------------------------- /tests/jest-rtl/styled-components/README.md: -------------------------------------------------------------------------------- 1 | ## styled-components with theme 2 | 3 | The component is styled using [styled-components](https://www.styled-components.com/) themes. This means the component requires `ThemeProvider` context. 4 | 5 | > We're not testing style output here. The purpose of this test is merely to illustrate how to use ThemeProvider in tests. 6 | -------------------------------------------------------------------------------- /tests/jest-enzyme/styled-components/README.md: -------------------------------------------------------------------------------- 1 | ## styled-components with theme 2 | 3 | The component is styled using [styled-components](https://www.styled-components.com/) themes. This means the component requires `ThemeProvider` context. 4 | 5 | > We're not testing style output here. The purpose of this test is merely to illustrate how to use ThemeProvider in tests. 6 | -------------------------------------------------------------------------------- /tests/jest-interactor/styled-components/README.md: -------------------------------------------------------------------------------- 1 | ## styled-components with theme 2 | 3 | The component is styled using [styled-components](https://www.styled-components.com/) themes. This means the component requires `ThemeProvider` context. 4 | 5 | > We're not testing style output here. The purpose of this test is merely to illustrate how to use ThemeProvider in tests. 6 | -------------------------------------------------------------------------------- /ui/contexts.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import { createContext } from 'react'; 4 | 5 | type FileOptionsValue = { 6 | showComments: boolean, 7 | showImports: boolean 8 | }; 9 | 10 | export const FileOptions = createContext({ 11 | showComments: false, 12 | showImports: false 13 | }); 14 | 15 | export const GitRef = createContext('master'); 16 | -------------------------------------------------------------------------------- /tests/shared/components/UserWithRouter.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { withRouter } from 'react-router'; 3 | import { Link } from 'react-router-dom'; 4 | 5 | function UserPage({ match: { params } }) { 6 | return ( 7 |
8 | User #{params.userId} Next user 9 |
10 | ); 11 | } 12 | 13 | export const UserWithRouter = withRouter(UserPage); 14 | -------------------------------------------------------------------------------- /ui/svg/settings.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /tests/jest-enzyme/render-text/test.js: -------------------------------------------------------------------------------- 1 | // highlight{8,10} 2 | import React from 'react'; 3 | import { mount } from 'enzyme'; 4 | import { HelloMessage } from 'shared/components/HelloMessage'; 5 | 6 | it('renders personalized greeting', () => { 7 | // Render new instance in every test to prevent leaking state 8 | const wrapper = mount(); 9 | 10 | expect(wrapper.text()).toMatch(/hello Satoshi/i); 11 | }); 12 | -------------------------------------------------------------------------------- /ui/import-files.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | /* eslint-env commonjs */ 3 | 4 | import type { TTestKinds } from './types'; 5 | 6 | // NOTE: Tests are populated at compile time via import-tests-loader, because 7 | // we're lazy (smart) and don't want to update this file whenever we add a test 8 | export const testKinds: TTestKinds = {}; 9 | 10 | // NOTE: This is also replaced at compile time with the latest commit SHA 11 | export const gitRef: string = ''; 12 | -------------------------------------------------------------------------------- /ui/svg/question.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /ui/components/File/Code/__fixtures__/xhr.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import { createFixture } from 'react-cosmos-classic'; 4 | import { Code } from '..'; 5 | import { testKinds } from '../../../../import-files'; 6 | 7 | export default [ 8 | createFixture({ 9 | component: Code, 10 | props: { 11 | code: testKinds['jest-enzyme'].tests[6].files['test.js'], 12 | showComments: true, 13 | showImports: false 14 | } 15 | }) 16 | ]; 17 | -------------------------------------------------------------------------------- /ui/svg/clippy.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /ui/svg/link.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /tests/jest-rtl/render-text/test.js: -------------------------------------------------------------------------------- 1 | // highlight{8,10} 2 | import React from 'react'; 3 | import { render, waitForElement } from 'react-testing-library'; 4 | import { HelloMessage } from 'shared/components/HelloMessage'; 5 | 6 | it('renders personalized greeting', async () => { 7 | // Render new instance in every test to prevent leaking state 8 | const { getByText } = render(); 9 | 10 | await waitForElement(() => getByText(/hello Satoshi/i)); 11 | }); 12 | -------------------------------------------------------------------------------- /tests/shared/components/StatefulCounter.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | 3 | export class StatefulCounter extends Component { 4 | state = { 5 | count: 0 6 | }; 7 | 8 | increment = () => { 9 | this.setState({ count: this.state.count + 1 }); 10 | }; 11 | 12 | render() { 13 | return ( 14 |
15 | Clicked {this.state.count} times{' '} 16 | 17 |
18 | ); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tests/jest-enzyme/click-callback/test.js: -------------------------------------------------------------------------------- 1 | // highlight{9,11} 2 | import React from 'react'; 3 | import { mount } from 'enzyme'; 4 | import { Button } from 'shared/components/Button'; 5 | 6 | it('calls "onClick" prop on button click', () => { 7 | // Render new instance in every test to prevent leaking state 8 | const onClick = jest.fn(); 9 | const wrapper = mount( 8 | 9 | ); 10 | } 11 | 12 | function mapStateToProps({ count }) { 13 | return { count }; 14 | } 15 | 16 | const mapDispatchToProps = { 17 | increment: () => ({ type: 'INCREMENT' }) 18 | }; 19 | 20 | export const ReduxCounter = connect(mapStateToProps, mapDispatchToProps)( 21 | Counter 22 | ); 23 | -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | 3 | [include] 4 | 5 | [libs] 6 | flow-typed 7 | 8 | [lints] 9 | 10 | [options] 11 | module.name_mapper='^!raw-loader!.+$' -> '/.stubs/raw.js' 12 | module.name_mapper='^!.+\/readme-text-loader!.+$' -> '/.stubs/readme-text.js' 13 | module.name_mapper='^.+\/README\.md$' -> '/.stubs/component.js' 14 | module.name_mapper='^.+\/SETUP\.md$' -> '/.stubs/component.js' 15 | module.name_mapper='^.+\/WHATSTHIS\.md$' -> '/.stubs/component.js' 16 | module.name_mapper='^.+\/CREDITS\.md$' -> '/.stubs/component.js' 17 | 18 | [strict] 19 | -------------------------------------------------------------------------------- /CREDITS.md: -------------------------------------------------------------------------------- 1 | Tests powered by [Jest](https://jestjs.io/) [react-mock](https://github.com/skidding/react-mock) [Enzyme](https://airbnb.io/enzyme/) [react-testing-library](https://github.com/kentcdodds/react-testing-library) and [@bigtest/interactor](https://github.com/bigtestjs/interactor). Website powered by [Babel](https://babeljs.io/) [Cosmos](https://github.com/react-cosmos/react-cosmos) [MDX](https://mdxjs.com/) [Next.js](https://nextjs.org/) [Prism](https://prismjs.com/) [styled-components](https://www.styled-components.com/) [webpack](https://webpack.js.org/) and many more. 2 | 3 | Finally, [React](https://reactjs.org/) makes it all possible! 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Testing Examples 2 | 3 | [CircleCI](https://circleci.com/gh/skidding/react-testing-examples) 4 | 5 | Searchable library of React testing examples 6 | 7 | - **[Open the library](https://react-testing-examples.netlify.app)** 8 | - [Read about the project](https://react-testing-examples.netlify.app/about) 9 | - [Browse test files](tests) 10 | - [Read contributing guide](CONTRIBUTING.md) 11 | 12 | [Screenshot of React Testing Examples](https://react-testing-examples.netlify.app) 13 | -------------------------------------------------------------------------------- /ui/svg/info.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /ui/types.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import type { ComponentType } from 'react'; 4 | 5 | export type TTestKindId = string; 6 | 7 | export type TReadmeMeta = { 8 | title: string, 9 | body: Array 10 | }; 11 | 12 | export type TReadme = { 13 | meta: TReadmeMeta, 14 | component: ComponentType<*> 15 | }; 16 | 17 | export type TSection = { 18 | name: string, 19 | readme: TReadme, 20 | files: { [string]: string } 21 | }; 22 | 23 | export type TTestKind = { 24 | id: TTestKindId, 25 | order: string[], 26 | setup: TSection, 27 | tests: TSection[] 28 | }; 29 | 30 | export type TTestKinds = { 31 | [id: TTestKindId]: TTestKind 32 | }; 33 | -------------------------------------------------------------------------------- /ui/server/testFiles.js: -------------------------------------------------------------------------------- 1 | // XXX: File uses CJS exports to be require-able by Next.js config 2 | const { join } = require('path'); 3 | const glob = require('glob'); 4 | 5 | const TESTS_PATH = join(__dirname, `../../tests`); 6 | 7 | exports.getTestKindIds = function() { 8 | return getDirNames(TESTS_PATH).filter(t => t !== 'shared'); 9 | }; 10 | 11 | exports.getTestNames = function(testKindId) { 12 | return getDirNames(join(TESTS_PATH, testKindId)); 13 | }; 14 | 15 | function getDirNames(dirPath) { 16 | return ( 17 | glob 18 | .sync(`*/`, { cwd: dirPath }) 19 | // Remove trailing slash 20 | .map(t => t.replace(/\/$/, '')) 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /ui/cosmos-proxies/globalStyle.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createGlobalStyle } from 'styled-components'; 3 | import { GlobalStyle } from '../global-style'; 4 | 5 | export function GlobalStyleProxy({ nextProxy, ...otherProps }) { 6 | const { value: NextProxy, next } = nextProxy; 7 | const { fixture } = otherProps; 8 | 9 | return ( 10 | <> 11 | 12 | {fixture.bgColor && } 13 | 14 | 15 | ); 16 | } 17 | 18 | const CustomBg = createGlobalStyle` 19 | html, body { 20 | background: ${props => props.bgColor}; 21 | } 22 | `; 23 | -------------------------------------------------------------------------------- /ui/components/SectionList/ToggleShow.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import React, { Component } from 'react'; 4 | import ReactShow from 'react-show'; 5 | 6 | import type { Node } from 'react'; 7 | 8 | type Props = { 9 | header: ({ show: boolean, onToggle: () => mixed }) => Node, 10 | content: Node, 11 | show: boolean, 12 | onToggle: () => mixed 13 | }; 14 | 15 | export class ToggleShow extends Component { 16 | render() { 17 | const { header, content, show, onToggle } = this.props; 18 | 19 | return ( 20 | <> 21 |
{header({ show, onToggle })}
22 | {content} 23 | 24 | ); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /ui/svg/mark-github.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /ui/components/Section/__fixtures__/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import { createFixture } from 'react-cosmos-classic'; 4 | import { Section } from '..'; 5 | import { testKinds } from '../../../import-files'; 6 | 7 | export default [ 8 | createFixture({ 9 | name: 'Local component state', 10 | component: Section, 11 | props: { 12 | testKindId: 'jest-enzyme', 13 | section: testKinds['jest-enzyme'].tests[2], 14 | searchText: '' 15 | } 16 | }), 17 | createFixture({ 18 | name: 'Fetch test', 19 | component: Section, 20 | props: { 21 | testKindId: 'jest-enzyme', 22 | section: testKinds['jest-enzyme'].tests[6], 23 | searchText: '' 24 | } 25 | }) 26 | ]; 27 | -------------------------------------------------------------------------------- /tests/jest-rtl/xhr/README.md: -------------------------------------------------------------------------------- 1 | ## XHR requests 2 | 3 | The component reads and updates a server counter using the [XHR API](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest). 4 | 5 | We test that the component renders the counter value from the mocked API response. Then we click on the increment button, which makes a POST request to increment the counter, and afterwards test that the component renders the incremented value. 6 | 7 | > These tests are **async** because server requests don't resolve immediately. We wait for the button to appear before interacting with our component. 8 | 9 | > We use [`@react-mock/xhr`](https://github.com/skidding/react-mock/tree/master/packages/xhr) to mock the server requests. 10 | -------------------------------------------------------------------------------- /tests/jest-rtl/fetch/README.md: -------------------------------------------------------------------------------- 1 | ## Fetch requests 2 | 3 | The component reads and updates a server counter using the [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API). 4 | 5 | We test that the component renders the counter value from the mocked API response. Then we click on the increment button, which makes a POST request to increment the counter, and afterwards test that the component renders the incremented value. 6 | 7 | > These tests are **async** because server requests don't resolve immediately. We wait for the button to appear before interacting with our component. 8 | 9 | > We use [`@react-mock/fetch`](https://github.com/skidding/react-mock/tree/master/packages/fetch) to mock the server requests. 10 | -------------------------------------------------------------------------------- /ui/shared/section.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import type { TTestKindId, TSection } from '../types'; 4 | 5 | type Props = { 6 | testKindId: TTestKindId, 7 | sectionName: ?string 8 | }; 9 | 10 | export function hasSectionChanged(props: Props, prevProps: Props) { 11 | return ( 12 | props.testKindId !== prevProps.testKindId || 13 | props.sectionName !== prevProps.sectionName 14 | ); 15 | } 16 | 17 | export function getSectionByName( 18 | sections: TSection[], 19 | sectionName: string 20 | ): TSection { 21 | const section = sections.find(s => s.name === sectionName); 22 | 23 | if (!section) { 24 | throw new Error(`Not found section with name "${sectionName}"`); 25 | } 26 | 27 | return section; 28 | } 29 | -------------------------------------------------------------------------------- /tests/jest-interactor/xhr/README.md: -------------------------------------------------------------------------------- 1 | ## XHR requests 2 | 3 | The component reads and updates a server counter using the [XHR API](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest). 4 | 5 | We test that the component renders the counter value from the mocked API response. Then we click on the increment button, which makes a POST request to increment the counter, and afterwards test that the component renders the incremented value. 6 | 7 | > These tests are **async** because server requests don't resolve immediately. We wait for the button to appear before interacting with our component. 8 | 9 | > We use [`@react-mock/xhr`](https://github.com/skidding/react-mock/tree/master/packages/xhr) to mock the server requests. 10 | -------------------------------------------------------------------------------- /tests/jest-interactor/fetch/README.md: -------------------------------------------------------------------------------- 1 | ## Fetch requests 2 | 3 | The component reads and updates a server counter using the [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API). 4 | 5 | We test that the component renders the counter value from the mocked API response. Then we click on the increment button, which makes a POST request to increment the counter, and afterwards test that the component renders the incremented value. 6 | 7 | > These tests are **async** because server requests don't resolve immediately. We wait for the button to appear before interacting with our component. 8 | 9 | > We use [`@react-mock/fetch`](https://github.com/skidding/react-mock/tree/master/packages/fetch) to mock the server requests. 10 | -------------------------------------------------------------------------------- /ui/components/Header/AboutButton.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import React from 'react'; 4 | import Link from 'next/link'; 5 | import styled from 'styled-components'; 6 | import svgQuestion from '../../svg/question.svg'; 7 | import { IconButton } from './styles'; 8 | 9 | export function AboutButton() { 10 | return ( 11 | 12 | 13 | 14 | About 15 | 16 | 17 | ); 18 | } 19 | 20 | const AboutIconButton = styled(IconButton)` 21 | margin-left: 16px; 22 | 23 | @media (max-width: 399px) { 24 | .label { 25 | display: none; 26 | } 27 | `; 28 | -------------------------------------------------------------------------------- /ui/components/Header/GithubLink.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import React from 'react'; 4 | import styled from 'styled-components'; 5 | import svgMarkGithub from '../../svg/mark-github.svg'; 6 | import { IconButton } from './styles'; 7 | 8 | export function GithubLink() { 9 | return ( 10 | 16 | 17 | GitHub 18 | 19 | ); 20 | } 21 | 22 | const GitHubIconButton = styled(IconButton)` 23 | margin-left: 24px; 24 | 25 | @media (max-width: 359px) { 26 | .label { 27 | display: none; 28 | } 29 | } 30 | `; 31 | -------------------------------------------------------------------------------- /ui/components/Header/styles.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import styled from 'styled-components'; 4 | import { Button } from '../shared/styles'; 5 | 6 | export const IconButton = styled(Button)` 7 | display: flex; 8 | height: 24px; 9 | font-weight: 400; 10 | color: #20232a; 11 | line-height: 24px; 12 | text-decoration: none; 13 | 14 | .icon { 15 | width: 24px; 16 | height: 24px; 17 | background: url(${props => props.icon}); 18 | background-size: 20px; 19 | background-position: center center; 20 | background-repeat: no-repeat; 21 | opacity: 0.7; 22 | } 23 | 24 | .label { 25 | padding-left: 4px; 26 | white-space: nowrap; 27 | } 28 | 29 | :hover { 30 | .label { 31 | text-decoration: underline; 32 | } 33 | } 34 | `; 35 | -------------------------------------------------------------------------------- /.babelrc.js: -------------------------------------------------------------------------------- 1 | const testSharedAlias = { 2 | root: ['./tests'], 3 | alias: { 4 | shared: './tests/shared' 5 | } 6 | }; 7 | 8 | module.exports = { 9 | presets: ['next/babel', '@babel/preset-flow'], 10 | plugins: [ 11 | ['babel-plugin-inline-import-data-uri', { extensions: ['.png', '.svg'] }], 12 | ['styled-components', { ssr: true, displayName: true, preprocess: false }], 13 | ['module-resolver', testSharedAlias], 14 | ['@babel/plugin-proposal-decorators', { decoratorsBeforeExport: true }] 15 | ], 16 | env: { 17 | test: { 18 | // Jest runs in Node and needs CommonJS modules. So does SSR, but Next 19 | // runs webpack on the server as well nowadays 20 | presets: [['next/babel', { 'preset-env': { modules: 'commonjs' } }]] 21 | } 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /tests/jest-interactor/click-callback/test.js: -------------------------------------------------------------------------------- 1 | // highlight{11,16-18} 2 | import React from 'react'; 3 | import { Button } from 'shared/components/Button'; 4 | import Interactor from '@bigtest/interactor'; 5 | import { mount } from '@bigtest/react'; 6 | 7 | it('calls "onClick" prop on button click', async () => { 8 | // Render new instance in every test to prevent leaking state 9 | const onClick = jest.fn(); 10 | // Mount the component in the DOM 11 | await mount(() => 32 | 33 | ); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /ui/global-style.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import { createGlobalStyle } from 'styled-components'; 4 | 5 | export const GlobalStyle = createGlobalStyle` 6 | html, body { 7 | margin: 0; 8 | padding: 0; 9 | background: #20232a; 10 | color: #888e9c; 11 | font-family: -apple-system, BlinkMacSystemFont, Ubuntu, 'Helvetica Neue', Helvetica, sans-serif; 12 | font-size: 16px; 13 | } 14 | 15 | body.with-modal { 16 | overflow: hidden; 17 | } 18 | 19 | ul, ol, li, p, h1, h2, input, button, select { 20 | margin: 0; 21 | padding: 0; 22 | } 23 | 24 | input, button, select { 25 | font-family: -apple-system, BlinkMacSystemFont, Ubuntu, 'Helvetica Neue', Helvetica, sans-serif; 26 | font-size: 16px; 27 | } 28 | 29 | a { 30 | color: #3058b5; 31 | font-weight: 500; 32 | text-decoration: none; 33 | 34 | :hover { 35 | text-decoration: underline; 36 | } 37 | } 38 | `; 39 | -------------------------------------------------------------------------------- /ui/components/File/FileHeader.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import React from 'react'; 4 | import styled from 'styled-components'; 5 | import { FileActions } from './FileActions'; 6 | 7 | type Props = { 8 | name: string, 9 | filePath: string, 10 | code: string 11 | }; 12 | 13 | export function FileHeader({ name, filePath, code }: Props) { 14 | return ( 15 | 16 | 17 | 18 | 19 | 20 | 21 | ); 22 | } 23 | 24 | const Container = styled.div` 25 | display: flex; 26 | padding: 0 24px; 27 | height: 40px; 28 | line-height: 40px; 29 | border-radius: 10px; 30 | transition: background 0.3s; 31 | `; 32 | 33 | const Label = styled.div` 34 | flex-grow: 1; 35 | color: rgba(32, 35, 42, 0.7); 36 | font-weight: 500; 37 | `; 38 | 39 | const Actions = styled.div` 40 | flex-grow: 0; 41 | `; 42 | -------------------------------------------------------------------------------- /ui/components/Footer.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import React from 'react'; 4 | import styled from 'styled-components'; 5 | import Credits from '../../CREDITS.md'; 6 | import { CenterText, Paragraph } from './shared/styles'; 7 | 8 | export function Footer() { 9 | return ( 10 | 11 | 12 | 16 | }} 17 | /> 18 | 19 | Made with love by{' '} 20 | 21 | Ovidiu 22 | 23 | 24 | 25 | 26 | ); 27 | } 28 | 29 | const Container = styled.div` 30 | padding: 32px 0 128px 0; 31 | background: #20232a; 32 | color: #888e9c; 33 | overflow: hidden; 34 | 35 | a { 36 | color: #dde0e8; 37 | } 38 | `; 39 | -------------------------------------------------------------------------------- /tests/jest-enzyme/local-state/test.js: -------------------------------------------------------------------------------- 1 | // highlight{10-12} 2 | import React from 'react'; 3 | import { mount } from 'enzyme'; 4 | import { StateMock } from '@react-mock/state'; 5 | import { StatefulCounter } from 'shared/components/StatefulCounter'; 6 | 7 | // Hoist helper functions (but not vars) to reuse between test cases 8 | const getWrapper = ({ count }) => 9 | mount( 10 | 11 | 12 | 13 | ); 14 | 15 | it('renders initial count', () => { 16 | // Render new instance in every test to prevent leaking state 17 | const wrapper = getWrapper({ count: 5 }); 18 | 19 | expect(wrapper.text()).toMatch(/clicked 5 times/i); 20 | }); 21 | 22 | it('increments count', () => { 23 | // Render new instance in every test to prevent leaking state 24 | const wrapper = getWrapper({ count: 5 }); 25 | 26 | wrapper.find('button').simulate('click'); 27 | expect(wrapper.text()).toMatch(/clicked 6 times/i); 28 | }); 29 | -------------------------------------------------------------------------------- /tests/shared/components/PersistentForm.js: -------------------------------------------------------------------------------- 1 | /* eslint-env browser */ 2 | 3 | import React, { Component } from 'react'; 4 | 5 | export class PersistentForm extends Component { 6 | state = { 7 | name: 'Guest' 8 | }; 9 | 10 | componentDidMount() { 11 | this.setState({ 12 | name: localStorage.getItem('name') 13 | }); 14 | } 15 | 16 | changeName = e => { 17 | e.preventDefault(); 18 | 19 | const name = this.refs.nameField.value; 20 | this.setState({ name }, () => { 21 | localStorage.setItem('name', name); 22 | }); 23 | }; 24 | 25 | render() { 26 | const { name } = this.state; 27 | 28 | return ( 29 | <> 30 |

Welcome, {name}!

31 |
32 | 33 | 34 | 35 |
36 | 37 | ); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tests/jest-interactor/styled-components/test.js: -------------------------------------------------------------------------------- 1 | // highlight{17,21,23-25} 2 | import React from 'react'; 3 | import { mount } from '@bigtest/react'; 4 | import { themeLight } from 'shared/theme'; 5 | import Interactor from '@bigtest/interactor'; 6 | import { ThemeProvider } from 'styled-components'; 7 | import { HelloMessageStyled } from 'shared/components/HelloMessageStyled'; 8 | 9 | // Hoist helper functions (but not vars) to reuse between test cases 10 | const renderComponent = ({ theme, name }) => 11 | mount(() => ( 12 | 13 | 14 | 15 | )); 16 | 17 | const helloMessage = new Interactor('span'); 18 | 19 | it('renders greeting', async () => { 20 | // Render new instance in every test to prevent leaking state 21 | await renderComponent({ theme: themeLight, name: 'Maggie' }); 22 | 23 | await helloMessage.when(() => 24 | expect(helloMessage.text).toEqual('Hello Maggie') 25 | ); 26 | }); 27 | -------------------------------------------------------------------------------- /tests/shared/components/ServerFetchCounter.js: -------------------------------------------------------------------------------- 1 | /* eslint-env browser */ 2 | 3 | import React, { Component } from 'react'; 4 | 5 | export class ServerCounter extends Component { 6 | state = { 7 | isSyncing: true, 8 | count: 0 9 | }; 10 | 11 | componentDidMount() { 12 | fetch('/count') 13 | .then(res => res.json()) 14 | .then(({ count }) => { 15 | this.setState({ isSyncing: false, count }); 16 | }); 17 | } 18 | 19 | increment = () => { 20 | this.setState({ isSyncing: true }); 21 | 22 | fetch('/count', { method: 'POST' }) 23 | .then(res => res.json()) 24 | .then(({ count }) => { 25 | this.setState({ isSyncing: false, count }); 26 | }); 27 | }; 28 | 29 | render() { 30 | const { isSyncing, count } = this.state; 31 | 32 | return isSyncing ? ( 33 |
Syncing...
34 | ) : ( 35 |
36 | Clicked {count} times 37 |
38 | ); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /ui/components/shared/SectionLink.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import React from 'react'; 4 | import Link from 'next/link'; 5 | import queryString from 'query-string'; 6 | import { DEFAULT_TEST_KIND_ID } from '../../shared/testKinds'; 7 | 8 | import type { Node } from 'react'; 9 | import type { TTestKindId } from '../../types'; 10 | 11 | type Props = { 12 | children: Node, 13 | testKindId: TTestKindId, 14 | sectionName?: string 15 | }; 16 | 17 | export function SectionLink({ children, testKindId, sectionName }: Props) { 18 | const params = { testKindId, sectionName }; 19 | const url = getUrl(testKindId, sectionName); 20 | 21 | return ( 22 | 23 | {children} 24 | 25 | ); 26 | } 27 | 28 | export function getUrl(testKindId: TTestKindId, sectionName?: string) { 29 | if (sectionName) { 30 | return `/${testKindId}/${sectionName}`; 31 | } 32 | 33 | return testKindId === DEFAULT_TEST_KIND_ID ? '/' : `/${testKindId}`; 34 | } 35 | -------------------------------------------------------------------------------- /ui/pages/_document.js: -------------------------------------------------------------------------------- 1 | import Document, { Head, Main, NextScript } from 'next/document'; 2 | import { ServerStyleSheet } from 'styled-components'; 3 | 4 | export default class MyDocument extends Document { 5 | static getInitialProps({ renderPage }) { 6 | const sheet = new ServerStyleSheet(); 7 | const page = renderPage(App => props => 8 | sheet.collectStyles() 9 | ); 10 | const styleTags = sheet.getStyleElement(); 11 | 12 | return { ...page, styleTags }; 13 | } 14 | 15 | render() { 16 | return ( 17 | 18 | 19 | 20 | 21 | 22 | 23 | {this.props.styleTags} 24 | 25 | 26 |
27 | 28 | 29 | 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /ui/next.config.js: -------------------------------------------------------------------------------- 1 | const { addGlobalEntry, addLoaders } = require('./webpack.extend'); 2 | const { getTestKindIds, getTestNames } = require('./server/testFiles'); 3 | 4 | module.exports = { 5 | webpack: (config, { defaultLoaders }) => { 6 | return addLoaders(addGlobalEntry(config), defaultLoaders.babel); 7 | }, 8 | exportPathMap() { 9 | return { 10 | '/': { page: '/' }, 11 | '/about': { page: '/about' }, 12 | ...getTestPaths() 13 | }; 14 | } 15 | }; 16 | 17 | function getTestPaths() { 18 | let paths = {}; 19 | 20 | getTestKindIds().forEach(testKindId => { 21 | paths[`/${testKindId}`] = { page: '/', query: { testKindId } }; 22 | paths[`/${testKindId}/setup`] = { 23 | page: '/', 24 | query: { testKindId, sectionName: 'setup' } 25 | }; 26 | 27 | getTestNames(testKindId).forEach(testName => { 28 | paths[`/${testKindId}/${testName}`] = { 29 | page: '/', 30 | query: { testKindId, sectionName: testName } 31 | }; 32 | }); 33 | }); 34 | 35 | return paths; 36 | } 37 | -------------------------------------------------------------------------------- /tests/jest-rtl/local-state/test.js: -------------------------------------------------------------------------------- 1 | // highlight{10-12} 2 | import React from 'react'; 3 | import { render, waitForElement, fireEvent } from 'react-testing-library'; 4 | import { StateMock } from '@react-mock/state'; 5 | import { StatefulCounter } from 'shared/components/StatefulCounter'; 6 | 7 | // Hoist helper functions (but not vars) to reuse between test cases 8 | const renderComponent = ({ count }) => 9 | render( 10 | 11 | 12 | 13 | ); 14 | 15 | it('renders initial count', async () => { 16 | // Render new instance in every test to prevent leaking state 17 | const { getByText } = renderComponent({ count: 5 }); 18 | 19 | await waitForElement(() => getByText(/clicked 5 times/i)); 20 | }); 21 | 22 | it('increments count', async () => { 23 | // Render new instance in every test to prevent leaking state 24 | const { getByText } = renderComponent({ count: 5 }); 25 | 26 | fireEvent.click(getByText('+1')); 27 | await waitForElement(() => getByText(/clicked 6 times/i)); 28 | }); 29 | -------------------------------------------------------------------------------- /tests/jest-enzyme/redux/test.js: -------------------------------------------------------------------------------- 1 | // highlight{12-14} 2 | import React from 'react'; 3 | import { createStore } from 'redux'; 4 | import { Provider } from 'react-redux'; 5 | import { mount } from 'enzyme'; 6 | import { ReduxCounter } from 'shared/components/ReduxCounter'; 7 | import { counterReducer } from './reducer'; 8 | 9 | // Hoist helper functions (but not vars) to reuse between test cases 10 | const getWrapper = ({ count }) => 11 | mount( 12 | 13 | 14 | 15 | ); 16 | 17 | it('renders initial count', () => { 18 | // Render new instance in every test to prevent leaking state 19 | const wrapper = getWrapper({ count: 5 }); 20 | 21 | expect(wrapper.text()).toMatch(/clicked 5 times/i); 22 | }); 23 | 24 | it('increments count', () => { 25 | // Render new instance in every test to prevent leaking state 26 | const wrapper = getWrapper({ count: 5 }); 27 | 28 | wrapper.find('button').simulate('click'); 29 | expect(wrapper.text()).toMatch(/clicked 6 times/i); 30 | }); 31 | -------------------------------------------------------------------------------- /ui/cosmos-proxies/next.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import App, { Container } from 'next/app'; 3 | import { createRouter } from 'next/router'; 4 | 5 | // XXX I don't know what I'm doing here! Here are some files I sniffed thru: 6 | // - https://github.com/zeit/next.js/blob/965f50beb238eab8aafa9be32833b0e7d2947574/packages/next/client/index.js#L94-L101 7 | // - https://github.com/zeit/next.js/blob/82d56e063aad12ac8fee5b9d5ed24ccf725b1a5b/packages/next-server/lib/router/index.js#L98-L103 8 | // - https://github.com/zeit/next.js/blob/785377d3c306b5c89c566f59a846b7829e627654/packages/next/pages/_app.js#L17-L23 9 | createRouter('/', {}, '/'); 10 | 11 | class NextApp extends App { 12 | render() { 13 | return {this.props.children}; 14 | } 15 | } 16 | 17 | export function NextRouterProxy({ nextProxy, ...otherProps }) { 18 | const { value: NextProxy, next } = nextProxy; 19 | 20 | return ( 21 | 22 | 23 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /ui/components/File/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import React, { Component } from 'react'; 4 | import styled from 'styled-components'; 5 | import { FileOptions } from '../../contexts'; 6 | import { Center } from '../shared/styles'; 7 | import { Code } from './Code'; 8 | import { FileHeader } from './FileHeader'; 9 | 10 | type Props = { 11 | name: string, 12 | filePath: string, 13 | code: string 14 | }; 15 | 16 | export class File extends Component { 17 | render() { 18 | const { name, filePath, code } = this.props; 19 | 20 | return ( 21 | 22 | 23 | {({ showComments, showImports }) => ( 24 | <> 25 | 26 | 31 | 32 | )} 33 | 34 | 35 | ); 36 | } 37 | } 38 | 39 | const Container = styled(Center)` 40 | margin-bottom: 16px; 41 | `; 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Ovidiu Cherecheș 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/jest-rtl/redux/test.js: -------------------------------------------------------------------------------- 1 | // highlight{12-14} 2 | import React from 'react'; 3 | import { createStore } from 'redux'; 4 | import { Provider } from 'react-redux'; 5 | import { render, waitForElement, fireEvent } from 'react-testing-library'; 6 | import { ReduxCounter } from 'shared/components/ReduxCounter'; 7 | import { counterReducer } from './reducer'; 8 | 9 | // Hoist helper functions (but not vars) to reuse between test cases 10 | const renderComponent = ({ count }) => 11 | render( 12 | 13 | 14 | 15 | ); 16 | 17 | it('renders initial count', async () => { 18 | // Render new instance in every test to prevent leaking state 19 | const { getByText } = renderComponent({ count: 5 }); 20 | 21 | await waitForElement(() => getByText(/clicked 5 times/i)); 22 | }); 23 | 24 | it('increments count', async () => { 25 | // Render new instance in every test to prevent leaking state 26 | const { getByText } = renderComponent({ count: 5 }); 27 | 28 | fireEvent.click(getByText('+1')); 29 | await waitForElement(() => getByText(/clicked 6 times/i)); 30 | }); 31 | -------------------------------------------------------------------------------- /tests/jest-rtl/react-router/test.js: -------------------------------------------------------------------------------- 1 | // highlight{10-14,31-33} 2 | import React from 'react'; 3 | import { MemoryRouter, Route } from 'react-router'; 4 | import { render, waitForElement, fireEvent } from 'react-testing-library'; 5 | import { UserWithRouter } from 'shared/components/UserWithRouter'; 6 | 7 | // Hoist helper functions (but not vars) to reuse between test cases 8 | const renderComponent = ({ userId }) => 9 | render( 10 | 11 | 12 | 13 | 14 | 15 | ); 16 | 17 | it('renders initial user id', async () => { 18 | // Render new instance in every test to prevent leaking state 19 | const { getByText } = renderComponent({ userId: 5 }); 20 | 21 | await waitForElement(() => getByText(/user #5/i)); 22 | }); 23 | 24 | it('renders next user id', async () => { 25 | // Render new instance in every test to prevent leaking state 26 | const { getByText } = renderComponent({ userId: 5 }); 27 | 28 | fireEvent.click(getByText(/next user/i)); 29 | await waitForElement(() => getByText(/user #6/i)); 30 | }); 31 | -------------------------------------------------------------------------------- /tests/jest-interactor/README.md: -------------------------------------------------------------------------------- 1 | ## Setup 2 | 3 | Minimal setup required to use [@bigtest/interactor](https://github.com/bigtestjs/interactor) with [Jest](https://jestjs.io/). 4 | 5 | [Interactors](https://www.bigtestjs.io/docs/interactor/) define a part 6 | of an app that tests act upon. They are immutable, reusable, and 7 | composable. Which means you can write expressive tests that are fast, 8 | robust, and match the composibility of the components you're testing. 9 | 10 | Typically, when writing tests for your components, you will use a 11 | custom interactor (like `counter-interactor.js` below). This allows 12 | you to define all the ways someone _interacts_ with your 13 | component. Can they type in it? Click it? Drag it? This is all done in 14 | a way that doesn't tie you to a framework or implementation 15 | details. Interactors are able to do that because they're _converging_ 16 | on the state of the DOM. They're not aware of your components 17 | implentation, only what is reflected in the DOM. 18 | 19 | If you don't need a custom interactor, you can import the `Interactor` 20 | class and pass the selector of the element you want to interact with. 21 | 22 | > All examples featured here run using these exact config files. 23 | -------------------------------------------------------------------------------- /ui/components/Section/TitleLink.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import React from 'react'; 4 | import styled from 'styled-components'; 5 | import svgLink from '../../svg/link.svg'; 6 | import { SectionLink } from '../shared/SectionLink'; 7 | import { H2 } from '../shared/styles'; 8 | 9 | import type { Node } from 'react'; 10 | import type { TTestKindId } from '../../types'; 11 | 12 | type Props = { 13 | children: Node, 14 | testKindId: TTestKindId, 15 | sectionName: string 16 | }; 17 | 18 | export function TitleLink({ children, testKindId, sectionName }: Props) { 19 | return ( 20 | 21 | 22 | 38 | }} 39 | /> 40 | 41 | ); 42 | } 43 | } 44 | 45 | export const Container = styled.div` 46 | margin-bottom: 24px; 47 | `; 48 | -------------------------------------------------------------------------------- /tests/jest-interactor/react-router/test.js: -------------------------------------------------------------------------------- 1 | // highlight{19-23,25,29,31,36,39} 2 | import React from 'react'; 3 | import { MemoryRouter, Route } from 'react-router'; 4 | import { mount } from '@bigtest/react'; 5 | import { interactor, clickable } from '@bigtest/interactor'; 6 | import { UserWithRouter } from 'shared/components/UserWithRouter'; 7 | 8 | // Hoist helper functions (but not vars) to reuse between test cases 9 | const renderComponent = ({ userId }) => 10 | mount(() => ( 11 | 12 | 13 | 14 | 15 | 16 | )); 17 | 18 | // Create custom interactor for interacting with this page 19 | @interactor 20 | class UserPage { 21 | // click the next user link 22 | next = clickable('a'); 23 | } 24 | 25 | let page = new UserPage(); 26 | 27 | it('renders initial user id', async () => { 28 | // Render new instance in every test to prevent leaking state 29 | await renderComponent({ userId: 5 }); 30 | 31 | await page.when(() => expect(page.text).toContain('User #5')); 32 | }); 33 | 34 | it('renders next user id', async () => { 35 | // Render new instance in every test to prevent leaking state 36 | await renderComponent({ userId: 5 }); 37 | 38 | // click the next link and assert the page route changed 39 | await page.next().when(() => expect(page.text).toContain('User #6')); 40 | }); 41 | -------------------------------------------------------------------------------- /ui/webpack.extend.js: -------------------------------------------------------------------------------- 1 | // XXX: Kids, don't try this at home, but it seems this is an entry that is 2 | // loaded on both server and client 3 | const UNIVERSAL_APP_ENTRY_MATCH = 'static/.+?/pages/_app.js'; 4 | 5 | module.exports = { 6 | addGlobalEntry(config) { 7 | const origEntry = config.entry; 8 | const entry = async () => { 9 | const entries = await origEntry(); 10 | 11 | const entryNames = Object.keys(entries); 12 | const appEntry = entryNames.find(e => e.match(UNIVERSAL_APP_ENTRY_MATCH)); 13 | 14 | if (!appEntry) { 15 | return entries; 16 | } 17 | 18 | return { 19 | ...entries, 20 | [appEntry]: [require.resolve('./global'), ...entries[appEntry]] 21 | }; 22 | }; 23 | 24 | return { 25 | ...config, 26 | entry 27 | }; 28 | }, 29 | 30 | addLoaders: function(config, babelLoader) { 31 | const { module } = config; 32 | const { rules } = module; 33 | 34 | const mdxRule = { 35 | test: /(README|SETUP|WHATSTHIS|CREDITS).md$/, 36 | use: [babelLoader, '@mdx-js/loader'] 37 | }; 38 | const importFilesRule = { 39 | test: require.resolve('./import-files'), 40 | use: require.resolve('./webpack-loaders/import-tests-loader') 41 | }; 42 | 43 | return { 44 | ...config, 45 | module: { 46 | ...module, 47 | rules: [...rules, mdxRule, importFilesRule] 48 | } 49 | }; 50 | } 51 | }; 52 | -------------------------------------------------------------------------------- /ui/components/App.fixture.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import { createFixture } from 'react-cosmos-classic'; 4 | import { App } from './App'; 5 | import { gitRef } from '../import-files'; 6 | import { getTestKind } from '../shared/testKinds'; 7 | 8 | export default [ 9 | createFixture({ 10 | name: 'react-testing-library', 11 | component: App, 12 | props: { 13 | gitRef, 14 | testKind: getTestKind('jest-rtl'), 15 | showAbout: false 16 | }, 17 | state: {} 18 | }), 19 | createFixture({ 20 | name: 'react-testing-library Fetch', 21 | component: App, 22 | props: { 23 | gitRef, 24 | testKind: getTestKind('jest-rtl'), 25 | sectionName: 'fetch', 26 | showAbout: false 27 | }, 28 | state: {} 29 | }), 30 | createFixture({ 31 | name: 'Enzyme', 32 | component: App, 33 | props: { 34 | gitRef, 35 | testKind: getTestKind('jest-enzyme'), 36 | showAbout: false 37 | }, 38 | state: {} 39 | }), 40 | createFixture({ 41 | name: 'Enzyme setup', 42 | component: App, 43 | props: { 44 | gitRef, 45 | testKind: getTestKind('jest-enzyme'), 46 | sectionName: 'setup', 47 | showAbout: false 48 | }, 49 | state: {} 50 | }), 51 | createFixture({ 52 | name: 'About', 53 | component: App, 54 | props: { 55 | gitRef, 56 | testKind: getTestKind('jest-rtl'), 57 | showAbout: true 58 | } 59 | }) 60 | ]; 61 | -------------------------------------------------------------------------------- /ui/components/Section/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import React from 'react'; 4 | import styled from 'styled-components'; 5 | import { CenterText } from '../shared/styles'; 6 | import { File } from '../File'; 7 | import { Readme } from './Readme'; 8 | 9 | import type { TTestKindId, TSection } from '../../types'; 10 | 11 | type Props = { 12 | testKindId: TTestKindId, 13 | section: TSection, 14 | searchText: string 15 | }; 16 | 17 | export function Section({ testKindId, section, searchText }: Props) { 18 | const { name, readme, files } = section; 19 | 20 | return ( 21 | 22 | 23 | 29 | 30 | {Object.keys(files).map(filePath => ( 31 | 37 | ))} 38 | 39 | ); 40 | } 41 | 42 | function getFilePath(testKindId, sectionName, filePath) { 43 | return sectionName === 'setup' 44 | ? `${testKindId}/${filePath}` 45 | : `${testKindId}/${sectionName}/${filePath}`; 46 | } 47 | 48 | const SectionContainer = styled.div` 49 | margin-top: 48px; 50 | background: #f5f7f9; 51 | color: #20232a; 52 | `; 53 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Javascript Node CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-javascript/ for more details 4 | # 5 | version: 2 6 | jobs: 7 | build: 8 | docker: 9 | # specify the version you desire here 10 | - image: circleci/node:8 11 | # Specify service dependencies here if necessary 12 | # CircleCI maintains a library of pre-built images 13 | # documented at https://circleci.com/docs/2.0/circleci-images/ 14 | # - image: circleci/mongo:3.4.4 15 | 16 | working_directory: ~/repo 17 | 18 | steps: 19 | - checkout 20 | 21 | # Download and cache dependencies 22 | - restore_cache: 23 | keys: 24 | - v1-dependencies-{{ checksum "package.json" }} 25 | # fallback to using the latest cache if no exact match is found 26 | - v1-dependencies- 27 | 28 | - run: yarn install 29 | 30 | - save_cache: 31 | paths: 32 | - node_modules 33 | key: v1-dependencies-{{ checksum "package.json" }} 34 | 35 | # Check code 36 | - run: yarn lint 37 | - run: yarn flow 38 | 39 | # Test code 40 | - run: yarn test:ci 41 | # The resource_class feature allows configuring CPU and RAM resources for each job. Different resource classes are available for different executors. https://circleci.com/docs/2.0/configuration-reference/#resourceclass 42 | resource_class: large 43 | -------------------------------------------------------------------------------- /WHATSTHIS.md: -------------------------------------------------------------------------------- 1 | ## Dear friend, 2 | 3 | Presenting these examples took work. I hope they'll make your life easier! 4 | 5 | ### Why put this together? 6 | 7 | There's a lot of wisdom that goes into writing clean tests. With every project I learn something new. I wanted to document my latest testing style and use it as a go-to resource for future projects. 8 | 9 | ### How does it work? 10 | 11 | The test examples are up to date and run in [CircleCI](https://circleci.com/gh/skidding/react-testing-examples). This searchable library is generated from README and test files [available on GitHub](https://github.com/skidding/react-testing-examples/tree/master/tests). 12 | 13 | ### Is this useful to you? 14 | 15 | The testing examples are _opinionated_. They aim to mimic user behavior and avoid testing abstract components. But you're free to disagree with [my testing philosophy](https://medium.com/@skidding/testing-react-components-30516bc6a1b3). The examples also feature modern libraries and agnostic testing techniques. 16 | 17 | ### What's the focus? 18 | 19 | _The component setup_. Performing actions and assertions is already well documented by the tools that handle event simulation and/or expectations. The examples here focus on how to wire up various component types for testing. 20 | 21 | ### Want to contribute? 22 | 23 | Let's harness our collective knowledge to create a great resource for testing React components! 24 | 25 | Best,
26 | [Ovidiu](https://twitter.com/skidding) 27 | -------------------------------------------------------------------------------- /ui/components/shared/FuzzyHighlighter.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import React from 'react'; 4 | import styled from 'styled-components'; 5 | import { match } from 'fuzzaldrin-plus'; 6 | import { shouldSearch } from '../../search'; 7 | 8 | type Props = { 9 | searchText: string, 10 | targetText: string 11 | }; 12 | 13 | export function FuzzyHighlighter({ searchText, targetText }: Props) { 14 | if (!shouldSearch(searchText)) { 15 | return targetText; 16 | } 17 | 18 | const fuzzyMatch = match(targetText, searchText); 19 | if (fuzzyMatch.length === 0) { 20 | return targetText; 21 | } 22 | 23 | const chars = []; 24 | fuzzyMatch.forEach((hlIndex, index) => { 25 | // If the first character isn't highlighted, push the initial 26 | // unhighlighted characters 27 | if (index === 0 && hlIndex !== 0) { 28 | chars.push(targetText.slice(0, hlIndex)); 29 | } 30 | 31 | // Push the highlighted character 32 | const hlChar = targetText.slice(hlIndex, hlIndex + 1); 33 | chars.push({hlChar}); 34 | 35 | // If the next character isn't highlighted, 36 | // push the subsequent unhighlighted characters 37 | const nextHlIndex = fuzzyMatch[index + 1]; 38 | if (nextHlIndex !== hlIndex + 1) { 39 | chars.push(targetText.slice(hlIndex + 1, nextHlIndex)); 40 | } 41 | }); 42 | 43 | return chars; 44 | } 45 | 46 | const Mark = styled.mark` 47 | background-color: rgba(255, 229, 100, 0.5); 48 | color: inherit; 49 | `; 50 | -------------------------------------------------------------------------------- /tests/jest-enzyme/localstorage/test.js: -------------------------------------------------------------------------------- 1 | // highlight{10-12} 2 | import React from 'react'; 3 | import { mount } from 'enzyme'; 4 | import { LocalStorageMock } from '@react-mock/localstorage'; 5 | import { PersistentForm } from 'shared/components/PersistentForm'; 6 | 7 | // Hoist helper functions (but not vars) to reuse between test cases 8 | const getWrapper = ({ name }) => 9 | mount( 10 | 11 | 12 | 13 | ); 14 | 15 | const submitForm = ({ wrapper, name }) => { 16 | wrapper.find('input').instance().value = name; 17 | wrapper.find('button').simulate('submit'); 18 | }; 19 | 20 | it('renders cached name', () => { 21 | // Render new instance in every test to prevent leaking state 22 | const wrapper = getWrapper({ name: 'Trent' }); 23 | 24 | expect(wrapper.text()).toMatch(/welcome, Trent/i); 25 | }); 26 | 27 | describe('on update', () => { 28 | it('renders updated name', () => { 29 | // Render new instance in every test to prevent leaking state 30 | const wrapper = getWrapper({ name: 'Trent' }); 31 | submitForm({ wrapper, name: 'Trevor' }); 32 | 33 | expect(wrapper.text()).toMatch(/welcome, Trevor/i); 34 | }); 35 | 36 | it('updates LocalStorage cache', () => { 37 | // Render new instance in every test to prevent leaking state 38 | const wrapper = getWrapper({ name: 'Trent' }); 39 | submitForm({ wrapper, name: 'Trevor' }); 40 | 41 | expect(localStorage.getItem('name')).toBe('Trevor'); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /ui/components/Header/Checkbox.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import React from 'react'; 4 | import styled from 'styled-components'; 5 | import { FileOptions } from '../../contexts'; 6 | 7 | type FileOptionCheckboxProps = { 8 | onToggle: () => mixed 9 | }; 10 | 11 | export function CommentsCheckbox({ onToggle }: FileOptionCheckboxProps) { 12 | return ( 13 | 14 | {({ showComments }) => ( 15 | 16 | )} 17 | 18 | ); 19 | } 20 | 21 | export function ImportsCheckbox({ onToggle }: FileOptionCheckboxProps) { 22 | return ( 23 | 24 | {({ showImports }) => ( 25 | 26 | )} 27 | 28 | ); 29 | } 30 | 31 | type CheckboxProps = { 32 | name: string, 33 | checked: boolean, 34 | onToggle: () => mixed 35 | }; 36 | 37 | function Checkbox({ name, checked, onToggle }: CheckboxProps) { 38 | return ( 39 | 40 | 41 | show {name} 42 | 43 | ); 44 | } 45 | 46 | const CheckboxLabel = styled.label` 47 | display: flex; 48 | align-items: center; 49 | margin-right: 12px; 50 | line-height: 24px; 51 | user-select: none; 52 | 53 | :last-child { 54 | margin-right: 0; 55 | } 56 | 57 | input { 58 | margin-left: 2px; 59 | margin-right: 6px; 60 | } 61 | `; 62 | -------------------------------------------------------------------------------- /tests/jest-rtl/fetch/test.js: -------------------------------------------------------------------------------- 1 | // highlight{10-17,33-36} 2 | import React from 'react'; 3 | import { render, fireEvent, waitForElement } from 'react-testing-library'; 4 | import { FetchMock } from '@react-mock/fetch'; 5 | import { ServerCounter } from 'shared/components/ServerFetchCounter'; 6 | 7 | // Hoist helper functions (but not vars) to reuse between test cases 8 | const renderComponent = ({ count }) => 9 | render( 10 | 16 | 17 | 18 | ); 19 | 20 | it('renders initial count', async () => { 21 | // Render new instance in every test to prevent leaking state 22 | const { getByText } = renderComponent({ count: 5 }); 23 | 24 | // It takes time for the counter to appear because 25 | // the GET request has a slight delay 26 | await waitForElement(() => getByText(/clicked 5 times/i)); 27 | }); 28 | 29 | it('increments count', async () => { 30 | // Render new instance in every test to prevent leaking state 31 | const { getByText } = renderComponent({ count: 5 }); 32 | 33 | // It takes time for the button to appear because 34 | // the GET request has a slight delay 35 | await waitForElement(() => getByText('+1')); 36 | fireEvent.click(getByText('+1')); 37 | 38 | // The counter doesn't update immediately because 39 | // the POST request is asynchronous 40 | await waitForElement(() => getByText(/clicked 6 times/i)); 41 | }); 42 | -------------------------------------------------------------------------------- /tests/jest-rtl/localstorage/test.js: -------------------------------------------------------------------------------- 1 | // highlight{10-12} 2 | import React from 'react'; 3 | import { render, fireEvent, waitForElement } from 'react-testing-library'; 4 | import { LocalStorageMock } from '@react-mock/localstorage'; 5 | import { PersistentForm } from 'shared/components/PersistentForm'; 6 | 7 | // Hoist helper functions (but not vars) to reuse between test cases 8 | const renderComponent = ({ name }) => 9 | render( 10 | 11 | 12 | 13 | ); 14 | 15 | const submitForm = ({ getByText, getByLabelText }, { name }) => { 16 | fireEvent.change(getByLabelText('Name'), { target: { value: name } }); 17 | fireEvent.click(getByText(/change name/i)); 18 | }; 19 | 20 | it('renders cached name', async () => { 21 | // Render new instance in every test to prevent leaking state 22 | const { getByText } = renderComponent({ name: 'Trent' }); 23 | 24 | await waitForElement(() => getByText(/welcome, Trent/i)); 25 | }); 26 | 27 | describe('on update', () => { 28 | it('renders updated name', async () => { 29 | // Render new instance in every test to prevent leaking state 30 | const utils = renderComponent({ name: 'Trent' }); 31 | submitForm(utils, { name: 'Trevor' }); 32 | 33 | await waitForElement(() => utils.getByText(/welcome, Trevor/i)); 34 | }); 35 | 36 | it('updates LocalStorage cache', () => { 37 | // Render new instance in every test to prevent leaking state 38 | const utils = renderComponent({ name: 'Trent' }); 39 | submitForm(utils, { name: 'Trevor' }); 40 | 41 | expect(localStorage.getItem('name')).toBe('Trevor'); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /tests/jest-interactor/fetch/test.js: -------------------------------------------------------------------------------- 1 | // highlight{22,26,30-32,37,43-45} 2 | import React from 'react'; 3 | import { mount } from '@bigtest/react'; 4 | import Counter from '../counter-interactor'; 5 | import { FetchMock } from '@react-mock/fetch'; 6 | import { ServerCounter } from 'shared/components/ServerFetchCounter'; 7 | 8 | // Hoist helper functions (but not vars) to reuse between test cases 9 | const renderComponent = ({ count }) => 10 | mount(() => ( 11 | 17 | 18 | 19 | )); 20 | 21 | // reuse the custom interactor for the same type of component 22 | let counter = new Counter(); 23 | 24 | it('renders initial count', async () => { 25 | // Render new instance in every test to prevent leaking state 26 | await renderComponent({ count: 5 }); 27 | 28 | // It takes time for the counter to appear because 29 | // the GET request has a slight delay 30 | await counter.when(() => 31 | expect(counter.clickedText).toContain('Clicked 5 times') 32 | ); 33 | }); 34 | 35 | it('increments count', async () => { 36 | // Render new instance in every test to prevent leaking state 37 | await renderComponent({ count: 5 }); 38 | 39 | // It takes time for the button to appear because 40 | // the GET request has a slight delay. 41 | // Interactions from interactor are chainable, 42 | // we can increment and then assert in the same chain. 43 | await counter 44 | .increment() 45 | .when(() => expect(counter.clickedText).toContain('Clicked 6 times')); 46 | }); 47 | -------------------------------------------------------------------------------- /tests/jest-rtl/xhr/test.js: -------------------------------------------------------------------------------- 1 | // highlight{15-22,38-41} 2 | import React from 'react'; 3 | import { render, fireEvent, waitForElement } from 'react-testing-library'; 4 | import { XhrMock } from '@react-mock/xhr'; 5 | import { ServerCounter } from 'shared/components/ServerXhrCounter'; 6 | 7 | // Hoist helper functions (but not vars) to reuse between test cases 8 | const getRes = count => async (req, res) => res.status(200).body({ count }); 9 | 10 | const postRes = count => (req, res) => 11 | res.status(200).body({ count: count + 1 }); 12 | 13 | const renderComponent = ({ count }) => 14 | render( 15 | 21 | 22 | 23 | ); 24 | 25 | it('renders initial count', async () => { 26 | // Render new instance in every test to prevent leaking state 27 | const { getByText } = renderComponent({ count: 5 }); 28 | 29 | // It takes time for the counter to appear because 30 | // the GET request has a slight delay 31 | await waitForElement(() => getByText(/clicked 5 times/i)); 32 | }); 33 | 34 | it('increments count', async () => { 35 | // Render new instance in every test to prevent leaking state 36 | const { getByText } = renderComponent({ count: 5 }); 37 | 38 | // It takes time for the button to appear because 39 | // the GET request has a slight delay 40 | await waitForElement(() => getByText('+1')); 41 | fireEvent.click(getByText('+1')); 42 | 43 | // The counter doesn't update immediately because 44 | // the POST request is asynchronous 45 | await waitForElement(() => getByText(/clicked 6 times/i)); 46 | }); 47 | -------------------------------------------------------------------------------- /ui/components/shared/styles.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import styled from 'styled-components'; 4 | 5 | export const MOBILE_BREAKPOINT = 666; 6 | 7 | export const Center = styled.div` 8 | margin: 0 auto; 9 | max-width: 720px; 10 | box-sizing: border-box; 11 | `; 12 | 13 | export const CenterText = styled(Center)` 14 | padding: 0 24px; 15 | `; 16 | 17 | export const Button = styled.button` 18 | box-sizing: border-box; 19 | border: 0; 20 | background: none; 21 | color: #20232a; 22 | cursor: pointer; 23 | outline: none; 24 | `; 25 | 26 | export const H2 = styled.h2` 27 | margin: 32px 0 24px 0; 28 | font-size: 28px; 29 | font-weight: 300; 30 | line-height: 36px; 31 | opacity: 0.8; 32 | `; 33 | 34 | export const H3 = styled.h3` 35 | margin: 24px 0 16px 0; 36 | font-size: 20px; 37 | font-weight: 500; 38 | line-height: 24px; 39 | `; 40 | 41 | export const Paragraph = styled.p` 42 | margin: 0 0 16px 0; 43 | line-height: 28px; 44 | `; 45 | 46 | export const Blockquote = styled.blockquote` 47 | margin: 0 0 16px 0; 48 | padding: 0 0 0 16px; 49 | border-left: 4px solid #dde0e8; 50 | 51 | p { 52 | color: rgba(32, 35, 42, 0.7); 53 | } 54 | `; 55 | 56 | export const InlineCode = styled.code` 57 | padding: 3px 6px; 58 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 59 | monospace; 60 | font-size: 14px; 61 | background-color: rgba(221, 224, 232, 0.7); 62 | border-radius: 3px; 63 | `; 64 | 65 | export const List = styled.ul` 66 | margin: 8px 0 16px 0; 67 | padding-left: 48px; 68 | `; 69 | 70 | export const ListItem = styled.li` 71 | line-height: 30px; 72 | margin: 4px 0; 73 | 74 | :first-child { 75 | margin-top: 0; 76 | } 77 | :last-child { 78 | margin-bottom: 0; 79 | } 80 | `; 81 | -------------------------------------------------------------------------------- /ui/components/Header/TestKindSelect.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import React, { Component } from 'react'; 4 | import Router from 'next/router'; 5 | import styled from 'styled-components'; 6 | import svgChevronDown from '../../svg/triangle-down.svg'; 7 | import { TEST_KIND_LABELS } from '../../shared/testKinds'; 8 | 9 | import type { TTestKindId } from '../../types'; 10 | 11 | type Props = { 12 | value: TTestKindId 13 | }; 14 | 15 | export class TestKindSelect extends Component { 16 | handleChange = (e: SyntheticInputEvent) => { 17 | const { value } = e.currentTarget; 18 | 19 | Router.push(`/index?testKindId=${value}`, `/${value}`); 20 | }; 21 | 22 | render() { 23 | const { value } = this.props; 24 | 25 | return ( 26 | 27 | {TEST_KIND_LABELS[value]} 28 | 35 | 36 | ); 37 | } 38 | } 39 | 40 | const SelectContainer = styled.div` 41 | position: relative; 42 | padding-right: 16px; 43 | height: 24px; 44 | background: url(${svgChevronDown}); 45 | background-size: 10px; 46 | background-position: calc(100%) 6px; 47 | background-repeat: no-repeat; 48 | line-height: 24px; 49 | white-space: nowrap; 50 | 51 | :hover { 52 | text-decoration: underline; 53 | } 54 | `; 55 | 56 | const Select = styled.select` 57 | position: absolute; 58 | top: 0; 59 | left: 0; 60 | width: 100%; 61 | height: 100%; 62 | border: 0; 63 | border-radius: 0; 64 | background: transparent; 65 | line-height: 24px; 66 | white-space: nowrap; 67 | cursor: pointer; 68 | appearance: none; 69 | opacity: 0; 70 | `; 71 | -------------------------------------------------------------------------------- /tests/jest-enzyme/fetch/test.js: -------------------------------------------------------------------------------- 1 | // highlight{12-19,23-25,34-37,45-49} 2 | import React from 'react'; 3 | import { mount } from 'enzyme'; 4 | import until from 'async-until'; 5 | import retry from '@skidding/async-retry'; 6 | import { FetchMock } from '@react-mock/fetch'; 7 | import { ServerCounter } from 'shared/components/ServerFetchCounter'; 8 | 9 | // Hoist helper functions (but not vars) to reuse between test cases 10 | const getWrapper = ({ count }) => 11 | mount( 12 | 18 | 19 | 20 | ); 21 | 22 | const isReady = wrapper => () => { 23 | // Enzyme wrapper is not updated automatically since v3 24 | // https://github.com/airbnb/enzyme/issues/1163 25 | wrapper.update(); 26 | 27 | return !wrapper.text().match(/syncing.../i); 28 | }; 29 | 30 | it('renders initial count', async () => { 31 | // Render new instance in every test to prevent leaking state 32 | const wrapper = getWrapper({ count: 5 }); 33 | 34 | // It takes time for the counter to appear because 35 | // the GET request has a slight delay 36 | await retry(() => { 37 | expect(wrapper.text()).toMatch(/clicked 5 times/i); 38 | }); 39 | }); 40 | 41 | it('increments count', async () => { 42 | // Render new instance in every test to prevent leaking state 43 | const wrapper = getWrapper({ count: 5 }); 44 | 45 | // It takes time for the button to appear because 46 | // the GET request has a slight delay 47 | await until(isReady(wrapper)); 48 | 49 | wrapper.find('button').simulate('click'); 50 | 51 | // The counter doesn't update immediately because 52 | // the POST request is asynchronous 53 | await retry(() => { 54 | expect(wrapper.text()).toMatch(/clicked 6 times/i); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /ui/server/start-dev.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import { join } from 'path'; 4 | import http from 'http'; 5 | import express from 'express'; 6 | import next from 'next'; 7 | 8 | const app = createApp(); 9 | const server = http.createServer(app); 10 | 11 | startNextApp(app, server); 12 | 13 | export function createApp(): express$Application { 14 | const app = express(); 15 | // This is where Express middlewares can be added 16 | // app.use(...); 17 | 18 | return app; 19 | } 20 | 21 | export async function startNextApp( 22 | app: express$Application, 23 | server: net$Server 24 | ) { 25 | const dev = process.env.NODE_ENV !== 'production'; 26 | const nextApp = next({ dev, dir: join(__dirname, '..') }); 27 | const nextHandler = nextApp.getRequestHandler(); 28 | 29 | await nextApp.prepare(); 30 | 31 | app.get('/about', (req: express$Request, res: express$Response) => { 32 | return nextApp.render(req, res, '/about'); 33 | }); 34 | 35 | app.get( 36 | '/:testKindId/:sectionName', 37 | (req: express$Request, res: express$Response) => { 38 | const { testKindId, sectionName } = req.params; 39 | 40 | return nextApp.render(req, res, '/index', { 41 | testKindId, 42 | sectionName 43 | }); 44 | } 45 | ); 46 | 47 | app.get('/:testKindId', (req: express$Request, res: express$Response) => { 48 | return nextApp.render(req, res, '/index', { 49 | testKindId: req.params.testKindId 50 | }); 51 | }); 52 | 53 | app.get('*', (req: express$Request, res: express$Response) => { 54 | return nextHandler(req, res); 55 | }); 56 | 57 | startServer(server, 3000); 58 | } 59 | 60 | export function startServer(server: net$Server, port: number) { 61 | // https://github.com/facebook/flow/issues/1684#issuecomment-222627634 62 | server.listen(port, undefined, undefined, err => { 63 | if (err) throw err; 64 | 65 | console.log(`> Ready on http://localhost:${port}`); 66 | }); 67 | } 68 | -------------------------------------------------------------------------------- /ui/components/File/Code/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import React from 'react'; 4 | import styled from 'styled-components'; 5 | import { extractCodeLines } from './shared'; 6 | import { StyledCode, PrismStyledCode } from './style'; 7 | 8 | type Props = { 9 | code: string, 10 | showComments: boolean, 11 | showImports: boolean 12 | }; 13 | 14 | export function Code({ code, showComments, showImports }: Props) { 15 | const lines = extractCodeLines({ code, showComments, showImports }); 16 | const visibleLines = lines.filter(l => !l.isHidden); 17 | 18 | return ( 19 | 20 | 21 | 22 | {visibleLines.map((line, idx) => ( 23 | 24 | {line.rawCode} 25 | 26 | ))} 27 | 28 | 29 | 30 | l.coloredCode).join('\n') 33 | }} 34 | /> 35 | 36 | 37 | ); 38 | } 39 | 40 | const Container = styled.div` 41 | position: relative; 42 | overflow: auto; 43 | `; 44 | 45 | const LineHighlights = styled.pre` 46 | background: #282c34; 47 | border-radius: 10px; 48 | margin: 0; 49 | padding: 16px 0; 50 | overflow: auto; 51 | color: transparent; 52 | `; 53 | 54 | const LineHighlight = styled.div` 55 | min-height: 24px; /* Required for empty lines to take space */ 56 | padding: 0 24px; 57 | background: ${props => (props.highlight ? '#14161a' : 'transparent')}; 58 | 59 | :last-child { 60 | min-height: auto; 61 | } 62 | `; 63 | 64 | const ColoredCode = styled.pre` 65 | /* Stay on top of the highlights */ 66 | position: absolute; 67 | top: 0; 68 | 69 | margin: 0; 70 | padding: 16px 24px; 71 | background: transparent; 72 | color: #ffffff; 73 | `; 74 | -------------------------------------------------------------------------------- /tests/jest-interactor/xhr/test.js: -------------------------------------------------------------------------------- 1 | // highlight{27,31,35-37,42,50-52} 2 | import React from 'react'; 3 | import { mount } from '@bigtest/react'; 4 | import Counter from '../counter-interactor'; 5 | import { XhrMock } from '@react-mock/xhr'; 6 | import { ServerCounter } from 'shared/components/ServerXhrCounter'; 7 | 8 | // Hoist helper functions (but not vars) to reuse between test cases 9 | const getRes = count => async (req, res) => res.status(200).body({ count }); 10 | 11 | const postRes = count => (req, res) => 12 | res.status(200).body({ count: count + 1 }); 13 | 14 | const renderComponent = ({ count }) => 15 | mount(() => ( 16 | 22 | 23 | 24 | )); 25 | 26 | // reuse the custom interactor for the same type of component 27 | let counter = new Counter(); 28 | 29 | it('renders initial count', async () => { 30 | // Render new instance in every test to prevent leaking state 31 | await renderComponent({ count: 5 }); 32 | 33 | // It takes time for the counter to appear because 34 | // the GET request has a slight delay 35 | await counter.when(() => 36 | expect(counter.clickedText).toContain('Clicked 5 times') 37 | ); 38 | }); 39 | 40 | it('increments count', async () => { 41 | // Render new instance in every test to prevent leaking state 42 | await renderComponent({ count: 5 }); 43 | 44 | // It takes time for the button to appear because 45 | // the GET request has a slight delay 46 | // The counter doesn't update immediately because 47 | // the POST request is asynchronous 48 | // Interactions from interactor are chainable, 49 | // we can increment and then assert in the same chain. 50 | await counter 51 | .increment() 52 | .when(() => expect(counter.clickedText).toContain('Clicked 6 times')); 53 | }); 54 | -------------------------------------------------------------------------------- /ui/components/Header/SearchBox.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | /* eslint-env browser */ 3 | 4 | import React, { Component } from 'react'; 5 | import styled from 'styled-components'; 6 | import { WindowKeyListener, KEY_S, KEY_ESC } from '../shared/WindowKeyListener'; 7 | 8 | type Props = { 9 | searchText: string, 10 | onChange: (searchText: string) => mixed 11 | }; 12 | 13 | export class SearchBox extends Component { 14 | searchInput: ?HTMLInputElement; 15 | 16 | handleChange = (e: SyntheticInputEvent) => { 17 | this.props.onChange(e.currentTarget.value); 18 | }; 19 | 20 | handleKeyDown = (e: SyntheticKeyboardEvent) => { 21 | const { searchInput } = this; 22 | if (!searchInput) { 23 | return; 24 | } 25 | 26 | const isFocused = searchInput === document.activeElement; 27 | if (e.keyCode === KEY_S && !isFocused) { 28 | // Prevent entering `s` in the search field along with focusing 29 | e.preventDefault(); 30 | searchInput.focus(); 31 | } else if (e.keyCode === KEY_ESC && isFocused) { 32 | this.props.onChange(''); 33 | searchInput.blur(); 34 | } 35 | }; 36 | 37 | render() { 38 | return ( 39 | 40 | { 42 | this.searchInput = node; 43 | }} 44 | type="text" 45 | placeholder="press 's' to search" 46 | value={this.props.searchText} 47 | onChange={this.handleChange} 48 | /> 49 | 50 | ); 51 | } 52 | } 53 | 54 | const SearchInput = styled.input` 55 | box-sizing: border-box; 56 | width: 272px; 57 | height: 32px; 58 | padding: 4px 16px; 59 | border: 0; 60 | border-radius: 5px; 61 | background: #dde0e8; 62 | color: #20232a; 63 | line-height: 24px; 64 | outline: none; 65 | 66 | ::placeholder { 67 | text-align: center; 68 | color: rgba(32, 35, 42, 0.5); 69 | } 70 | `; 71 | -------------------------------------------------------------------------------- /ui/pages/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import React, { Component } from 'react'; 4 | import Head from 'next/head'; 5 | import { App } from '../components/App'; 6 | import { gitRef } from '../import-files'; 7 | import { getSectionByName } from '../shared/section'; 8 | import { TEST_KIND_LABELS, getTestKind } from '../shared/testKinds'; 9 | 10 | import type { TTestKindId } from '../types'; 11 | 12 | type Query = { 13 | testKindId: TTestKindId, 14 | sectionName?: string 15 | }; 16 | 17 | type Props = { 18 | testKindId: TTestKindId, 19 | sectionName?: string 20 | }; 21 | 22 | export default class Page extends Component { 23 | static async getInitialProps({ query }: { query: Query }): Promise { 24 | const { testKindId, sectionName } = query; 25 | 26 | return { 27 | testKindId, 28 | sectionName 29 | }; 30 | } 31 | 32 | render() { 33 | const { testKindId, sectionName } = this.props; 34 | 35 | return ( 36 | <> 37 | 38 | {getPageTitle(testKindId, sectionName)} 39 | 40 | 45 | 46 | ); 47 | } 48 | } 49 | 50 | function getPageTitle(testKindId, sectionName) { 51 | const testKind = getTestKind(testKindId); 52 | 53 | return sectionName 54 | ? getSectionPageTitle(testKind, sectionName) 55 | : getTestKindPageTitle(testKind); 56 | } 57 | 58 | function getSectionPageTitle(testKind, sectionName) { 59 | const { setup, tests } = testKind; 60 | const section = getSectionByName([setup, ...tests], sectionName); 61 | 62 | return `${getSectionTitle(section)} — ${getTestKindPageTitle(testKind)}`; 63 | } 64 | 65 | function getTestKindPageTitle(testKind) { 66 | const testKindLabel = TEST_KIND_LABELS[testKind.id]; 67 | 68 | return `${testKindLabel} — React Testing Examples`; 69 | } 70 | 71 | function getSectionTitle(section) { 72 | return section.readme.meta.title; 73 | } 74 | -------------------------------------------------------------------------------- /tests/jest-enzyme/xhr/test.js: -------------------------------------------------------------------------------- 1 | // highlight{17-24,28-30,39-43,50-54} 2 | import React from 'react'; 3 | import { mount } from 'enzyme'; 4 | import until from 'async-until'; 5 | import retry from '@skidding/async-retry'; 6 | import { XhrMock } from '@react-mock/xhr'; 7 | import { ServerCounter } from 'shared/components/ServerXhrCounter'; 8 | 9 | // Hoist helper functions (but not vars) to reuse between test cases 10 | const getRes = count => async (req, res) => res.status(200).body({ count }); 11 | 12 | const postRes = count => (req, res) => 13 | res.status(200).body({ count: count + 1 }); 14 | 15 | const getWrapper = ({ count }) => 16 | mount( 17 | 23 | 24 | 25 | ); 26 | 27 | const isReady = wrapper => () => { 28 | // Enzyme wrapper is not updated automatically since v3 29 | // https://github.com/airbnb/enzyme/issues/1163 30 | wrapper.update(); 31 | 32 | return !wrapper.text().match(/syncing.../i); 33 | }; 34 | 35 | it('renders initial count', async () => { 36 | // Render new instance in every test to prevent leaking state 37 | const wrapper = getWrapper({ count: 5 }); 38 | 39 | // It takes time for the counter to appear because 40 | // the GET request has a slight delay 41 | await retry(() => { 42 | expect(wrapper.text()).toMatch(/clicked 5 times/i); 43 | }); 44 | }); 45 | 46 | it('increments count', async () => { 47 | // Render new instance in every test to prevent leaking state 48 | const wrapper = getWrapper({ count: 5 }); 49 | 50 | // It takes time for the button to appear because 51 | // the GET request has a slight delay 52 | await until(isReady(wrapper)); 53 | 54 | wrapper.find('button').simulate('click'); 55 | 56 | // The counter doesn't update immediately because 57 | // the POST request is asynchronous 58 | await retry(() => { 59 | expect(wrapper.text()).toMatch(/clicked 6 times/i); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /ui/components/File/Code/style.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import styled from 'styled-components'; 4 | 5 | export const StyledCode = styled.code` 6 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 7 | monospace !important; 8 | font-smooth: always; 9 | -webkit-font-smoothing: antialiased; 10 | -moz-osx-font-smoothing: grayscale; 11 | 12 | height: auto !important; 13 | margin: 0; 14 | font-size: 14px; 15 | line-height: 24px; 16 | white-space: pre-wrap; 17 | word-break: break-word; 18 | `; 19 | 20 | // Styles copied from 21 | // https://github.com/reactjs/reactjs.org/blob/942e83ef396199f499830792b1c61a9c6c990f29/src/prism-styles.js 22 | export const PrismStyledCode = styled(StyledCode)` 23 | .token.attr-name { 24 | color: #c5a5c5; 25 | } 26 | .token.comment, 27 | .token.block-comment, 28 | .token.prolog, 29 | .token.doctype, 30 | .token.cdata { 31 | color: #999999; 32 | } 33 | .token.property, 34 | .token.number, 35 | .token.function-name, 36 | .token.constant, 37 | .token.symbol, 38 | .token.deleted { 39 | color: #5a9bcf; 40 | } 41 | .token.boolean { 42 | color: #ff8b50; 43 | } 44 | .token.tag { 45 | color: #fc929e; 46 | } 47 | .token.string { 48 | color: #8dc891; 49 | } 50 | .token.punctuation { 51 | color: #5fb3b3; 52 | } 53 | .token.selector, 54 | .token.char, 55 | .token.builtin, 56 | .token.inserted { 57 | color: #d8dee9; 58 | } 59 | .token.function { 60 | color: #79b6f2; 61 | } 62 | .token.operator, 63 | .token.entity, 64 | .token.url, 65 | .token.variable { 66 | color: #d7deea; 67 | } 68 | .token.attr-value { 69 | color: #8dc891; 70 | } 71 | .token.keyword { 72 | color: #c5a5c5; 73 | } 74 | .token.atrule, 75 | .token.class-name { 76 | color: #fac863; 77 | } 78 | .token.important { 79 | font-weight: 400; 80 | } 81 | .token.bold { 82 | font-weight: 700; 83 | } 84 | .token.italic { 85 | font-style: italic; 86 | } 87 | .token.entity { 88 | cursor: help; 89 | } 90 | .namespace { 91 | opacity: 0.7; 92 | } 93 | `; 94 | -------------------------------------------------------------------------------- /tests/jest-interactor/localstorage/test.js: -------------------------------------------------------------------------------- 1 | // highlight{20-28,30,34,36,42,46-49,54,59-65} 2 | import React from 'react'; 3 | import { mount } from '@bigtest/react'; 4 | import { interactor, text, clickable, fillable } from '@bigtest/interactor'; 5 | import { LocalStorageMock } from '@react-mock/localstorage'; 6 | import { PersistentForm } from 'shared/components/PersistentForm'; 7 | 8 | // Hoist helper functions (but not vars) to reuse between test cases 9 | const renderComponent = ({ name }) => 10 | mount(() => ( 11 | 12 | 13 | 14 | )); 15 | 16 | // Create a custom reusable interactor for this form component. 17 | // These custom interactors aren't usually in your test files 18 | // and are reusable. So you write it once, import, and use it everywhere 19 | // this component is used. 20 | @interactor 21 | class FormInteractor { 22 | // Get the text of the `p` element 23 | welcomeText = text('p'); 24 | // Fill in the `#name-field` input with the passed value 25 | fillName = fillable('#name-field'); 26 | // Click the submit `button` 27 | submit = clickable('button'); 28 | } 29 | 30 | let form = new FormInteractor(); 31 | 32 | it('renders cached name', async () => { 33 | // Render new instance in every test to prevent leaking state 34 | await renderComponent({ name: 'Trent' }); 35 | 36 | await form.when(() => expect(form.welcomeText).toEqual('Welcome, Trent!')); 37 | }); 38 | 39 | describe('on update', () => { 40 | it('renders updated name', async () => { 41 | // Render new instance in every test to prevent leaking state 42 | await renderComponent({ name: 'Trent' }); 43 | 44 | // Fill the inputs name in with `Trevor`, submit the form, 45 | // and assert the change has taken place on the page 46 | await form 47 | .fillName('Trevor') 48 | .submit() 49 | .when(() => expect(form.welcomeText).toEqual('Welcome, Trevor!')); 50 | }); 51 | 52 | it('updates LocalStorage cache', async () => { 53 | // Render new instance in every test to prevent leaking state 54 | await renderComponent({ name: 'Trent' }); 55 | 56 | // Fill the inputs name in with `Bill`, submit the form, 57 | // and assert the change has taken place on the page 58 | // (and in local storage) 59 | await form 60 | .fillName('Bill') 61 | .submit() 62 | .when(() => { 63 | expect(form.welcomeText).toEqual('Welcome, Bill!'); 64 | expect(localStorage.getItem('name')).toBe('Bill'); 65 | }); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /ui/components/File/Code/shared.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import Prism from 'prismjs'; 4 | import { parseCode } from '../shared'; 5 | 6 | type ExtractCodeLineArgs = { 7 | code: string, 8 | showComments: boolean, 9 | showImports: boolean 10 | }; 11 | 12 | type CodeLine = { 13 | rawCode: string, 14 | coloredCode: string, 15 | isHidden: boolean, 16 | isHighlighted: boolean 17 | }; 18 | 19 | export function extractCodeLines({ 20 | code: rawCode, 21 | showComments, 22 | showImports 23 | }: ExtractCodeLineArgs): CodeLine[] { 24 | const { lineNumsToHighlight, cleanCode: code } = parseCode(rawCode); 25 | 26 | const coloredCode = Prism.highlight(code, Prism.languages.jsx, 'jsx'); 27 | const coloredLines = coloredCode.split('\n'); 28 | 29 | const lines = code.split('\n').map((line, lineIndex) => ({ 30 | rawCode: line, 31 | coloredCode: coloredLines[lineIndex], 32 | isHidden: isLineHidden({ code: line, showComments, showImports }), 33 | isHighlighted: isLineHighlighted({ lineIndex, lineNumsToHighlight }) 34 | })); 35 | 36 | const firstVisibleLine = getFirstVisibleLine(lines); 37 | 38 | // Ensure the visible code doesn't start with an empty line. This can happen 39 | // when imports are hidden 40 | return isLineEmpty(firstVisibleLine) 41 | ? hideLine(lines, firstVisibleLine) 42 | : lines; 43 | } 44 | 45 | function getFirstVisibleLine(lines) { 46 | return lines.filter(l => !l.isHidden)[0]; 47 | } 48 | 49 | function isLineEmpty(line) { 50 | return line && line.rawCode === ''; 51 | } 52 | 53 | function hideLine(lines, line) { 54 | const lineIndex = lines.indexOf(line); 55 | 56 | return [ 57 | ...lines.slice(0, lineIndex), 58 | { ...line, isHidden: true }, 59 | ...lines.slice(lineIndex + 1) 60 | ]; 61 | } 62 | 63 | function isLineHighlighted({ lineIndex, lineNumsToHighlight }) { 64 | // +1 line because lines start from 0 programatically, and +1 because we 65 | // remove the first line with the highlight{...} comment 66 | return lineNumsToHighlight.indexOf(lineIndex + 2) !== -1; 67 | } 68 | 69 | function isLineHidden({ code, showComments, showImports }) { 70 | return ( 71 | (!showComments && isCommentLine(code)) || 72 | (!showImports && isNoSideEffectsImportLine(code)) 73 | ); 74 | } 75 | 76 | function isNoSideEffectsImportLine(code) { 77 | // NOTE: Imported modules without imports are omitted 78 | // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import#Import_a_module_for_its_side_effects_only 79 | return Boolean(code.match(/^import [^']+/)); 80 | } 81 | 82 | function isCommentLine(code) { 83 | return Boolean(code.match(/^\s*\/\//)); 84 | } 85 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | ## Second version 2 | 3 | > Started on Oct 16, 2018 4 | 5 | - [x] Use react-mock in tests 6 | - [x] Remove hoisted vars and before/after cases 7 | - [x] Refactor code preview component 8 | - [x] Try react-testing-library 9 | - [x] Split tests between RTL and Enzyme 10 | - [x] Remove Cosmos fixture tests (superseded by react-mock) 11 | - [x] Refactor styled-components test 12 | - [x] Add URL for each test kind 13 | - [x] Add URL for each test 14 | - [x] Update project description 15 | - [x] Update README 16 | - [x] Visual facelift 17 | - [x] Header 18 | - [x] Remove file visibility toggling 19 | - [x] Tweak file actions 20 | - [x] Footer 21 | - [x] Links to tech used 22 | - [x] Update responsive breakpoints 23 | - [x] Replace file system sorting 24 | - [x] Make Next.js components load in Cosmos 25 | - [ ] Optimize search perf 26 | 27 | ## First version 28 | 29 | > Started on Apr 24, 2018 30 | 31 | - [x] Add "Click callback" tests 32 | - [x] Add "Render text" tests 33 | - [x] Add "Local state" tests 34 | - [x] Add "Redux" tests 35 | - [x] Add "React Router" tests 36 | - [x] Add "XHR" tests 37 | - [x] Add "Fetch" tests 38 | - [x] Add "LocalStorage" tests 39 | - [x] Add "styled-components" tests 40 | - [ ] Add "Context" tests 41 | - [x] Colocate components with tests 42 | - [x] Add ESLint 43 | - [x] Config CircleCI 44 | - [x] Colocate Cosmos proxies with tests 45 | - [x] Add Flow 46 | - [x] Create UI 47 | - [x] Syntax highlight (with line highlight) 48 | - [x] Sticky header 49 | - [x] Highlight and create fixtures for all tests 50 | - [x] Implement search 51 | - [x] Add toggle between 'Plain Enzyme' and 'Cosmos & Enzyme' 52 | - [x] Improve search 53 | - [x] Keyboard shortcuts 54 | - [x] No results screen 55 | - [x] Show best matches first 56 | - [x] Add links next to section titles 57 | - [x] Add file actions to copy and open code in GH 58 | - [x] Create About modal 59 | - [x] Footer 60 | - [x] Design no results screen 61 | - [x] Adapt header design on mobile 62 | - [x] Tweak colors 63 | - [x] Load test title & description from README pages 64 | - [x] Write copy 65 | - [x] Info overlay 66 | - [x] Opinionated test style (integration / abstract libs) 67 | - [x] Cosmos vs non-Cosmos 68 | - [x] Test READMEs 69 | - [x] Create build script 70 | - [x] Add Next.js 71 | - [x] Reconcile Babel config between Next, Cosmos and Jest 72 | - [x] Reconcile webpack config between Next and Cosmos 73 | - [x] Restructure files for more clarity 74 | - [x] Read tests from disk at compile time 75 | - [x] Point to latest commit SHA 76 | - [x] PUBLISH 77 | - [x] Create fixtures for UI components 78 | - [ ] Config Playground for tests 79 | -------------------------------------------------------------------------------- /ui/components/AboutModal.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import React, { Component } from 'react'; 4 | import Router from 'next/router'; 5 | import Link from 'next/link'; 6 | import styled from 'styled-components'; 7 | import WhatsThis from '../../WHATSTHIS.md'; 8 | import { CenterText, H2, H3, Button, Paragraph } from './shared/styles'; 9 | import { WindowKeyListener, KEY_ESC } from './shared/WindowKeyListener'; 10 | 11 | type Props = {}; 12 | 13 | export class AboutModal extends Component { 14 | handleContentClick = (e: SyntheticEvent) => { 15 | e.stopPropagation(); 16 | }; 17 | 18 | handleKeyDown = (e: SyntheticKeyboardEvent) => { 19 | if (e.keyCode === KEY_ESC) { 20 | goHome(); 21 | } 22 | }; 23 | 24 | render() { 25 | return ( 26 | 27 | 28 | 29 | , 35 | ol: OpinionsList 36 | }} 37 | /> 38 | 39 | 40 | Show me some tests 41 | 42 | 43 | 44 | 45 | 46 | ); 47 | } 48 | } 49 | 50 | function goHome() { 51 | Router.push('/'); 52 | } 53 | 54 | const Overlay = styled.div` 55 | position: fixed; 56 | top: 0; 57 | bottom: 0; 58 | left: 0; 59 | right: 0; 60 | z-index: 2; 61 | padding: 32px 12px; 62 | background: rgba(221, 224, 232, 0.9); 63 | overflow-x: hidden; 64 | overflow-y: auto; 65 | `; 66 | 67 | const Content = styled(CenterText)` 68 | background: #fff; 69 | border-radius: 10px; 70 | box-shadow: 0 3px 15px 0 rgba(32, 35, 42, 0.2); 71 | overflow: hidden; 72 | padding: 8px 24px 16px 24px; 73 | 74 | @media (min-width: 480px) { 75 | padding: 8px 36px 16px 36px; 76 | } 77 | `; 78 | 79 | const ButtonContainer = styled.div` 80 | text-align: right; 81 | `; 82 | 83 | const OpinionsList = styled.ol` 84 | padding-left: 36px; 85 | `; 86 | 87 | const SubtleLink = styled.a` 88 | color: #20232a; 89 | font-weight: 500; 90 | `; 91 | 92 | const GoButton = styled(Button)` 93 | display: inline-block; 94 | padding: 12px 20px; 95 | border-radius: 5px; 96 | background: #2b51ad; 97 | color: #f5f7f9; 98 | line-height: 24px; 99 | `; 100 | -------------------------------------------------------------------------------- /ui/webpack-loaders/import-tests-loader.js: -------------------------------------------------------------------------------- 1 | const { join } = require('path'); 2 | const glob = require('glob'); 3 | const { execSync } = require('child_process'); 4 | const { getTestKindIds, getTestNames } = require('../server/testFiles'); 5 | 6 | const TESTS_PATH = join(__dirname, `../../tests`); 7 | 8 | module.exports = function parseReadme(source) { 9 | const testKindsStr = getTestKindIds().map(testKindId => 10 | getTestKindStr(testKindId) 11 | ); 12 | 13 | const gitRef = getLastCommit(); 14 | 15 | // Re-build webpack bundle on test file changes 16 | this.addContextDependency(TESTS_PATH); 17 | 18 | return source 19 | .replace( 20 | 'testKinds: TTestKinds = {}', 21 | `testKinds: TTestKinds = { 22 | ${testKindsStr.join(`,\n `)} 23 | }` 24 | ) 25 | .replace(`gitRef: string = ''`, `gitRef = '${gitRef}'`); 26 | }; 27 | 28 | function getTestKindStr(testKindId) { 29 | return `'${testKindId}': { 30 | id: '${testKindId}', 31 | order: require('${getOrderPath(testKindId)}').default, 32 | setup: ${getSetupStr(testKindId)}, 33 | tests: [${getTestNames(testKindId) 34 | .map(testName => getTestStr(testKindId, testName)) 35 | .join(`, `)}] 36 | }`; 37 | } 38 | 39 | function getSetupStr(testKindId) { 40 | const readmePath = getSetupPath(testKindId, 'README.md'); 41 | 42 | return getSectionStr({ 43 | name: 'setup', 44 | readmePath, 45 | files: glob 46 | .sync(`*.js`, { cwd: getTestKindRootPath(testKindId) }) 47 | .filter(p => ['order.js'].indexOf(p) === -1) 48 | .map(p => getSetupPath(testKindId, p)) 49 | }); 50 | } 51 | 52 | function getTestStr(testKindId, testName) { 53 | const readmePath = getTestFilePath(testKindId, testName, 'README.md'); 54 | const testPath = getTestFilePath(testKindId, testName, 'test.js'); 55 | 56 | return getSectionStr({ 57 | name: testName, 58 | readmePath, 59 | files: [testPath] 60 | }); 61 | } 62 | 63 | function getSectionStr({ name, readmePath, files }) { 64 | return `{ 65 | name: '${name}', 66 | readme: ${getReadmeStr({ readmePath })}, 67 | files: { 68 | ${files.map(filePath => getFileStr({ filePath })).join(`,\n `)} 69 | } 70 | }`; 71 | } 72 | 73 | function getReadmeStr({ readmePath }) { 74 | const readmeTextLoader = getLoaderPath('readme-text-loader'); 75 | 76 | return `{ 77 | meta: require('!${readmeTextLoader}!${readmePath}'), 78 | component: require('${readmePath}').default 79 | }`; 80 | } 81 | 82 | function getFileStr({ filePath }) { 83 | const fileName = filePath.split('/').pop(); 84 | 85 | return `'${fileName}': require('!raw-loader!${filePath}')`; 86 | } 87 | 88 | function getSetupPath(testKindId, filePath) { 89 | return join(getTestKindRootPath(testKindId), filePath); 90 | } 91 | 92 | function getTestFilePath(testKindId, testName, filePath) { 93 | return join(getTestKindRootPath(testKindId), testName, filePath); 94 | } 95 | 96 | function getOrderPath(testKindId) { 97 | return join(getTestKindRootPath(testKindId), 'order.js'); 98 | } 99 | 100 | function getTestKindRootPath(testKindId) { 101 | return join(TESTS_PATH, testKindId); 102 | } 103 | 104 | function getLoaderPath(filePath) { 105 | return join(__dirname, `../webpack-loaders/${filePath}`); 106 | } 107 | 108 | function getLastCommit() { 109 | // Long live StackOverflow! 110 | return execSync(`git log | head -n 1 | awk '{print $2}'`) 111 | .toString() 112 | .trim(); 113 | } 114 | -------------------------------------------------------------------------------- /ui/components/File/FileActions.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import React, { Component } from 'react'; 4 | import styled from 'styled-components'; 5 | import Clipboard from 'clipboard'; 6 | import { GitRef } from '../../contexts'; 7 | import svgClippy from '../../svg/clippy.svg'; 8 | import svgLinkExternal from '../../svg/link-external.svg'; 9 | import { Button } from '../shared/styles'; 10 | import { parseCode } from './shared'; 11 | 12 | const { now } = Date; 13 | 14 | type Props = { 15 | filePath: string, 16 | code: string 17 | }; 18 | 19 | type CopyStatus = null | 'success' | 'error'; 20 | 21 | type State = { 22 | copyStatus: CopyStatus, 23 | copyTime: number 24 | }; 25 | 26 | export class FileActions extends Component { 27 | clipboard: ?typeof Clipboard; 28 | 29 | state = { 30 | copyStatus: null, 31 | copyTime: 0 32 | }; 33 | 34 | componentWillUnmount() { 35 | if (this.clipboard) { 36 | this.clipboard.destroy(); 37 | } 38 | } 39 | 40 | handleCopyBtnRef = (node: ?HTMLElement) => { 41 | if (node) { 42 | const clipboard = new Clipboard(node); 43 | clipboard.on('success', this.handleCopySuccess); 44 | clipboard.on('error', this.handleCopyError); 45 | 46 | this.clipboard = clipboard; 47 | } else { 48 | this.clipboard = null; 49 | } 50 | }; 51 | 52 | handleCopySuccess = () => { 53 | this.setState({ 54 | copyStatus: 'success', 55 | copyTime: now() 56 | }); 57 | }; 58 | 59 | handleCopyError = () => { 60 | this.setState({ 61 | copyStatus: 'error', 62 | copyTime: now() 63 | }); 64 | }; 65 | 66 | render() { 67 | const { filePath, code } = this.props; 68 | const { copyStatus, copyTime } = this.state; 69 | const { cleanCode } = parseCode(code); 70 | 71 | return ( 72 | 73 | {gitRef => ( 74 | 75 | 83 | 90 | 91 | )} 92 | 93 | ); 94 | } 95 | } 96 | 97 | const PROJECT_ROOT_URL = 'https://github.com/skidding/react-testing-examples'; 98 | 99 | function getFileUrl(gitRef: string, filePath: string) { 100 | return `${PROJECT_ROOT_URL}/blob/${gitRef}/tests/${filePath}`; 101 | } 102 | 103 | const Container = styled.div` 104 | display: flex; 105 | `; 106 | 107 | const Action = styled(Button)` 108 | width: 36px; 109 | height: 36px; 110 | padding: 0 8px; 111 | background: url(${props => props.icon}); 112 | background-size: 20px; 113 | background-position: center 6px; 114 | background-repeat: no-repeat; 115 | border-radius: 3px; 116 | opacity: 0.5; 117 | transition: opacity 0.3s; 118 | 119 | :hover { 120 | opacity: 0.9; 121 | } 122 | `; 123 | 124 | const CopyAction = styled(Action)` 125 | animation: flash${props => props.time} 3s; 126 | 127 | @keyframes flash${props => props.time} { 128 | from { 129 | background-color: ${props => getCopyBgColorByStatus(props.status)}; 130 | } 131 | to { 132 | background-color: transparent; 133 | } 134 | } 135 | `; 136 | 137 | function getCopyBgColorByStatus(status: CopyStatus) { 138 | switch (status) { 139 | case 'success': 140 | return '#64e88d'; 141 | case 'error': 142 | return '#f14342'; 143 | default: 144 | return 'transparent'; 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "lint": "eslint '**/*.js'", 4 | "test:jest-enzyme": "jest --config tests/jest-enzyme/jest.config.js", 5 | "test:jest-enzyme:watch": "yarn test:jest-enzyme --watch", 6 | "test:jest-rtl": "jest --config tests/jest-rtl/jest.config.js", 7 | "test:jest-rtl:watch": "yarn test:jest-rtl --watch", 8 | "test:jest-interactor": "jest --config tests/jest-interactor/jest.config.js", 9 | "test:jest-interactor:watch": "yarn test:jest-interactor --watch", 10 | "test:ui": "yarn jest --config ui/jest.config.js", 11 | "test:ui:watch": "yarn test:ui --watch", 12 | "test": "yarn test:jest-enzyme && yarn test:jest-rtl && yarn test:jest-interactor && yarn test:ui", 13 | "test:ci": "yarn test:jest-enzyme --maxWorkers=2 && yarn test:jest-rtl --maxWorkers=2 && yarn test:jest-interactor --maxWorkers=2 && yarn test:ui --maxWorkers=2", 14 | "cosmos": "cosmos-classic --config ui/cosmos.config", 15 | "dev": "yarn babel-node ui/server/start-dev.js", 16 | "build": "next build ui && next export ui -o .export", 17 | "upload": "cd .export && now --name rte --public && cd -" 18 | }, 19 | "devDependencies": { 20 | "@babel/cli": "7.1.2", 21 | "@babel/core": "7.1.2", 22 | "@babel/node": "^7.0.0", 23 | "@babel/plugin-proposal-class-properties": "7.1.0", 24 | "@babel/plugin-proposal-decorators": "^7.1.6", 25 | "@babel/plugin-proposal-object-rest-spread": "7.0.0", 26 | "@babel/preset-env": "7.1.0", 27 | "@babel/preset-flow": "7.0.0", 28 | "@babel/preset-react": "7.0.0", 29 | "@bigtest/interactor": "^0.9.1", 30 | "@bigtest/react": "^0.1.2", 31 | "@mdx-js/loader": "^0.15.5", 32 | "@mdx-js/mdx": "^0.15.5", 33 | "@react-mock/fetch": "^0.3.0", 34 | "@react-mock/localstorage": "^0.1.2", 35 | "@react-mock/state": "^0.1.7", 36 | "@react-mock/xhr": "^0.2.0", 37 | "@skidding/async-retry": "^1.0.2", 38 | "async-until": "^1.2.2", 39 | "axios": "^0.18.0", 40 | "babel-core": "^7.0.0-bridge.0", 41 | "babel-eslint": "^10.0.1", 42 | "babel-jest": "^23.6.0", 43 | "babel-loader": "^8.0.4", 44 | "babel-plugin-inline-import-data-uri": "^1.0.1", 45 | "babel-plugin-module-resolver": "^3.1.1", 46 | "babel-plugin-styled-components": "^1.8.0", 47 | "css-loader": "^1.0.0", 48 | "enzyme": "^3.7.0", 49 | "enzyme-adapter-react-16": "^1.6.0", 50 | "eslint": "^5.7.0", 51 | "eslint-import-resolver-babel-module": "^5.0.0-beta.1", 52 | "eslint-plugin-babel": "^5.2.1", 53 | "eslint-plugin-flowtype": "^3.0.0", 54 | "eslint-plugin-import": "^2.14.0", 55 | "eslint-plugin-jest": "^21.25.1", 56 | "eslint-plugin-react": "^7.11.1", 57 | "express": "^4.16.4", 58 | "flow-bin": "^0.93.0", 59 | "flow-typed": "^2.5.1", 60 | "glob": "^7.1.3", 61 | "html-webpack-plugin": "^3.2.0", 62 | "jest": "^24.1.0", 63 | "jest-dom": "^2.1.0", 64 | "prettier": "^1.16.4", 65 | "raw-loader": "^0.5.1", 66 | "react-cosmos-classic": "^4.8.3", 67 | "react-cosmos-fetch-proxy": "^4.8.2", 68 | "react-cosmos-localstorage-proxy": "^4.8.2", 69 | "react-cosmos-redux-proxy": "^4.8.2", 70 | "react-cosmos-router-proxy": "^4.8.2", 71 | "react-cosmos-test": "^4.8.2", 72 | "react-cosmos-xhr-proxy": "^4.8.2", 73 | "react-redux": "^5.0.7", 74 | "react-router": "^4.3.1", 75 | "react-router-dom": "^4.3.1", 76 | "react-test-renderer": "^16.5.2", 77 | "react-testing-library": "^5.2.0", 78 | "redux": "^4.0.1", 79 | "remark-parse": "^5.0.0", 80 | "style-loader": "^0.23.1", 81 | "traverse": "^0.6.6", 82 | "unified": "^7.0.0", 83 | "webpack": "^4.21.0" 84 | }, 85 | "dependencies": { 86 | "clipboard": "^2.0.4", 87 | "delay": "^4.1.0", 88 | "fuzzaldrin-plus": "^0.6.0", 89 | "next": "^7.0.2", 90 | "parse-numeric-range": "^0.0.2", 91 | "prismjs": "^1.15.0", 92 | "query-string": "^6.2.0", 93 | "react": "^16.8.3", 94 | "react-dom": "^16.8.3", 95 | "react-show": "2.0.4", 96 | "styled-components": "^4.1.3" 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /ui/components/SectionList/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import React, { Component } from 'react'; 4 | import styled from 'styled-components'; 5 | import { shouldSearch } from '../../search'; 6 | import svgInfo from '../../svg/info.svg'; 7 | import { SectionLink } from '../shared/SectionLink'; 8 | import { FuzzyHighlighter } from '../shared/FuzzyHighlighter'; 9 | import { hasSectionChanged } from '../../shared/section'; 10 | import { CenterText, Paragraph, List, ListItem } from '../shared/styles'; 11 | import thinkin from './img/thinkin.png'; 12 | import { ToggleShow } from './ToggleShow'; 13 | import { ToggleButton } from './ToggleButton'; 14 | 15 | import type { TTestKindId, TSection } from '../../types'; 16 | 17 | type Props = { 18 | sections: TSection[], 19 | testKindId: TTestKindId, 20 | sectionName: ?string, 21 | searchText: string, 22 | changeSearch: (searchText: string) => mixed 23 | }; 24 | 25 | type State = { 26 | isOpen: boolean 27 | }; 28 | 29 | export class SectionList extends Component { 30 | state = { 31 | isOpen: false 32 | }; 33 | 34 | componentDidUpdate(prevProps: Props) { 35 | if (this.state.isOpen && hasSectionChanged(this.props, prevProps)) { 36 | this.setState({ 37 | isOpen: false 38 | }); 39 | } 40 | } 41 | 42 | handleToggleList = () => { 43 | this.setState({ isOpen: !this.state.isOpen }); 44 | }; 45 | 46 | handleClearSearch = (e: SyntheticEvent) => { 47 | e.preventDefault(); 48 | this.props.changeSearch(''); 49 | }; 50 | 51 | render() { 52 | const { searchText } = this.props; 53 | const { isOpen } = this.state; 54 | 55 | if (shouldSearch(searchText)) { 56 | return ( 57 | 58 | {this.renderSearchTitle()} 59 | {this.renderContent()} 60 | 61 | ); 62 | } 63 | 64 | return ( 65 | 66 | ( 68 | 73 | )} 74 | content={this.renderContent()} 75 | show={isOpen} 76 | onToggle={this.handleToggleList} 77 | /> 78 | 79 | ); 80 | } 81 | 82 | renderSearchTitle() { 83 | const { sections, searchText } = this.props; 84 | 85 | if (!sections.length) { 86 | return ( 87 | 88 | No results found for "{searchText}" {this.renderClearSearchBtn()} 89 | 90 | ); 91 | } 92 | 93 | return ( 94 | 95 | Results for "{searchText}" {this.renderClearSearchBtn()} 96 | 97 | ); 98 | } 99 | 100 | renderContent() { 101 | const { sections, testKindId, sectionName, searchText } = this.props; 102 | 103 | if (!sections.length) { 104 | return ( 105 | <> 106 | 107 | 108 | 109 | 110 | Contact{' '} 111 |
112 | Ovidiu 113 | {' '} 114 | if you need help testing React components 115 | 116 | 117 | 118 | ); 119 | } 120 | 121 | return ( 122 | 123 | {sections.map(section => { 124 | const { name, readme } = section; 125 | const { title } = readme.meta; 126 | const hlText = ( 127 | 128 | ); 129 | 130 | return ( 131 | 132 | {sectionName === name ? ( 133 | {hlText} 134 | ) : ( 135 | 136 | {hlText} 137 | 138 | )} 139 | 140 | ); 141 | })} 142 | 143 | ); 144 | } 145 | 146 | renderClearSearchBtn() { 147 | return ( 148 | 149 | ( 150 | 151 | clear search 152 | 153 | ) 154 | 155 | ); 156 | } 157 | } 158 | 159 | const Container = styled(CenterText)` 160 | margin-top: 8px; 161 | `; 162 | 163 | const CustomList = styled(List)` 164 | margin: 0; 165 | padding-top: 0; 166 | padding-left: 24px; 167 | `; 168 | 169 | const SelectedItem = styled.span` 170 | color: #888e9c; 171 | font-weight: 500; 172 | `; 173 | 174 | const SearchHeader = styled.p` 175 | margin: 0; 176 | padding: 8px 0; 177 | line-height: 24px; 178 | `; 179 | 180 | const ClearSearchBtn = styled.span` 181 | opacity: 0.7; 182 | 183 | a { 184 | color: #20232a; 185 | white-space: nowrap; 186 | } 187 | `; 188 | 189 | const ThinkinFace = styled.div` 190 | margin: 32px auto; 191 | width: 140px; 192 | height: 140px; 193 | background: #f5f7f9 url(${thinkin}) no-repeat center center; 194 | background-size: 140px 140px; 195 | background-blend-mode: luminosity; 196 | opacity: 0; 197 | filter: blur(16px); 198 | animation: fadeIn 1s forwards; 199 | 200 | @keyframes fadeIn { 201 | from { 202 | opacity: 0; 203 | filter: blur(16px); 204 | } 205 | to { 206 | opacity: 1; 207 | filter: blur(0); 208 | } 209 | } 210 | `; 211 | 212 | const ContactParagraph = styled(Paragraph)` 213 | display: flex; 214 | flex-direction: row; 215 | justify-content: center; 216 | margin: 24px 0; 217 | 218 | .icon { 219 | display: inline-block; 220 | width: 24px; 221 | height: 24px; 222 | margin-right: 4px; 223 | background: url(${svgInfo}); 224 | background-size: 20px; 225 | background-position: center center; 226 | background-repeat: no-repeat; 227 | opacity: 0.5; 228 | flex-shrink: 0; 229 | } 230 | 231 | .text { 232 | color: rgba(32, 35, 42, 0.7); 233 | } 234 | `; 235 | -------------------------------------------------------------------------------- /ui/components/Header/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import React, { Component } from 'react'; 4 | import styled from 'styled-components'; 5 | import svgSettings from '../../svg/settings.svg'; 6 | import svgChevronLeft from '../../svg/chevron-left.svg'; 7 | import { MOBILE_BREAKPOINT, Center, Button } from '../shared/styles'; 8 | import { SectionLink } from '../shared/SectionLink'; 9 | import { TestKindSelect } from './TestKindSelect'; 10 | import { GithubLink } from './GithubLink'; 11 | import { AboutButton } from './AboutButton'; 12 | import { SearchBox } from './SearchBox'; 13 | import { CommentsCheckbox, ImportsCheckbox } from './Checkbox'; 14 | 15 | import type { TTestKindId } from '../../types'; 16 | 17 | type Props = { 18 | testKindId: TTestKindId, 19 | toggleComments: () => mixed, 20 | toggleImports: () => mixed, 21 | searchText: string, 22 | changeSearch: (searchText: string) => mixed 23 | }; 24 | 25 | type State = { 26 | mobileShowFilters: boolean 27 | }; 28 | 29 | export class Header extends Component { 30 | state = { 31 | mobileShowFilters: false 32 | }; 33 | 34 | handleMobileShowFilters = () => { 35 | this.setState({ mobileShowFilters: true }); 36 | }; 37 | 38 | handleMobileHideFilters = () => { 39 | this.setState({ mobileShowFilters: false }); 40 | }; 41 | 42 | render() { 43 | const { 44 | testKindId, 45 | toggleComments, 46 | toggleImports, 47 | searchText, 48 | changeSearch 49 | } = this.props; 50 | const { mobileShowFilters } = this.state; 51 | 52 | return ( 53 | 54 | 55 | 56 |

57 | 58 | changeSearch('')}>React Testing Examples 59 | 60 |

61 |
62 | 63 | 64 | 65 |
66 |
67 | 72 | 77 | 78 | 79 | 80 |
81 | 82 |
83 |
84 | {' '} 85 | {' '} 86 |
87 |
88 |
89 |
90 | ); 91 | } 92 | } 93 | 94 | const Container = styled.div` 95 | position: fixed; 96 | top: 0; 97 | width: 100%; 98 | min-width: 320px; 99 | height: 96px; 100 | padding: 8px 12px 4px 12px; 101 | box-sizing: border-box; 102 | background: #fff; 103 | box-shadow: 0 2px 0px 0px rgba(32, 35, 42, 0.15); 104 | color: #20232a; 105 | overflow: hidden; 106 | z-index: 1; 107 | 108 | @media (max-width: ${MOBILE_BREAKPOINT}px) { 109 | height: 80px; 110 | padding: 2px 12px 0 12px; 111 | } 112 | `; 113 | 114 | const Inner = styled(Center)` 115 | position: relative; 116 | height: 100%; 117 | `; 118 | 119 | const Left = styled.div` 120 | position: absolute; 121 | top: 0; 122 | bottom: 0; 123 | left: 0; 124 | 125 | h1 { 126 | position: absolute; 127 | top: 0; 128 | left: 0; 129 | color: rgb(32, 35, 42); 130 | line-height: 48px; 131 | font-size: 26px; 132 | font-weight: 700; 133 | font-style: italic; 134 | text-transform: uppercase; 135 | letter-spacing: 1px; 136 | white-space: nowrap; 137 | opacity: 0.8; 138 | 139 | a { 140 | font-weight: inherit; 141 | color: #20232a; 142 | text-decoration: none; 143 | } 144 | } 145 | 146 | .actions { 147 | display: flex; 148 | position: absolute; 149 | bottom: 8px; 150 | left: 0; 151 | height: 24px; 152 | line-height: 24px; 153 | } 154 | 155 | @media (max-width: ${MOBILE_BREAKPOINT}px) { 156 | h1 { 157 | font-size: 22px; 158 | } 159 | } 160 | `; 161 | 162 | const Right = styled.div` 163 | display: flex; 164 | flex-direction: column; 165 | justify-content: space-between; 166 | position: absolute; 167 | top: 0; 168 | bottom: 0; 169 | right: 0; 170 | 171 | .search { 172 | padding: 8px 0 0 8px; 173 | right: 0; 174 | } 175 | 176 | .toggles { 177 | display: flex; 178 | justify-content: center; 179 | height: 24px; 180 | padding: 0 0 8px 8px; 181 | line-height: 24px; 182 | text-align: center; 183 | } 184 | 185 | @media (max-width: ${MOBILE_BREAKPOINT}px) { 186 | .search { 187 | display: ${props => (props.mobileShowFilters ? 'block' : 'none')}; 188 | } 189 | 190 | .toggles { 191 | display: ${props => (props.mobileShowFilters ? 'flex' : 'none')}; 192 | } 193 | } 194 | `; 195 | 196 | const MobileShowFilters = styled(Button)` 197 | display: none; 198 | position: absolute; 199 | top: 0; 200 | right: 0; 201 | width: 40px; 202 | height: 48px; 203 | background: rgba(255, 255, 255, 0.9); 204 | background-image: url(${svgSettings}); 205 | background-size: 28px; 206 | background-position: center center; 207 | background-repeat: no-repeat; 208 | opacity: 0.9; 209 | 210 | @media (max-width: ${MOBILE_BREAKPOINT}px) { 211 | display: ${props => (props.filtersVisible ? 'none' : 'block')}; 212 | } 213 | `; 214 | 215 | const MobileHideFilters = styled(Button)` 216 | display: none; 217 | position: absolute; 218 | top: 0; 219 | left: 0; 220 | width: 100%; 221 | height: 100%; 222 | background: linear-gradient( 223 | to left, 224 | #fff, 225 | #fff 216px, 226 | rgba(255, 255, 255, 0.85) 227 | ); 228 | 229 | .icon { 230 | position: absolute; 231 | top: 0; 232 | left: 0; 233 | width: 56px; 234 | height: 80px; 235 | background-image: url(${svgChevronLeft}); 236 | background-size: 32px; 237 | background-position: center center; 238 | background-repeat: no-repeat; 239 | opacity: 0.9; 240 | } 241 | 242 | @media (max-width: ${MOBILE_BREAKPOINT}px) { 243 | display: ${props => (props.filtersVisible ? 'block' : 'none')}; 244 | } 245 | `; 246 | -------------------------------------------------------------------------------- /ui/components/App.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | /* eslint-env browser */ 3 | 4 | import React, { Component } from 'react'; 5 | import styled from 'styled-components'; 6 | import { sortBy } from 'lodash'; 7 | import { FileOptions, GitRef } from '../contexts'; 8 | import { shouldSearch, matchReadmeText, sortSections } from '../search'; 9 | import { hasSectionChanged, getSectionByName } from '../shared/section'; 10 | import { MOBILE_BREAKPOINT } from './shared/styles'; 11 | import { Header } from './Header'; 12 | import { AboutModal } from './AboutModal'; 13 | import { SectionList } from './SectionList'; 14 | import { Section } from './Section'; 15 | import { Footer } from './Footer'; 16 | 17 | import type { TTestKind, TSection } from '../types'; 18 | 19 | type Props = { 20 | gitRef: string, 21 | testKind: TTestKind, 22 | sectionName?: string, 23 | showAbout: boolean 24 | }; 25 | 26 | type State = { 27 | showComments: boolean, 28 | showImports: boolean, 29 | searchText: string 30 | }; 31 | 32 | export class App extends Component { 33 | static defaultProps = { 34 | showAbout: false 35 | }; 36 | 37 | state = { 38 | showAboutModal: false, 39 | showComments: true, 40 | showImports: false, 41 | searchText: '' 42 | }; 43 | 44 | handleToggleComments = () => { 45 | this.setState({ showComments: !this.state.showComments }); 46 | }; 47 | 48 | handleToggleImports = () => { 49 | this.setState({ showImports: !this.state.showImports }); 50 | }; 51 | 52 | handleSearchChange = (searchText: string) => { 53 | this.setState({ searchText }); 54 | }; 55 | 56 | componentDidMount() { 57 | setBodyScroll(this.props.showAbout); 58 | } 59 | 60 | componentDidUpdate(prevProps: Props, prevState: State) { 61 | const { showAbout } = this.props; 62 | const { searchText } = this.state; 63 | 64 | if ( 65 | searchText && 66 | hasSectionChanged(getSectionProps(this.props), getSectionProps(prevProps)) 67 | ) { 68 | this.setState({ 69 | searchText: '' 70 | }); 71 | } 72 | // Jump to top when changing search query, because results will change 73 | // anyway so previous scroll position will be irrelevant 74 | else if (searchText && searchText !== prevState.searchText) { 75 | window.scrollTo(0, 0); 76 | } 77 | 78 | if (showAbout !== prevProps.showAbout) { 79 | setBodyScroll(showAbout); 80 | } 81 | } 82 | 83 | render() { 84 | const { gitRef, testKind, sectionName, showAbout } = this.props; 85 | const { showComments, showImports, searchText } = this.state; 86 | 87 | const isSearching = shouldSearch(searchText); 88 | const sections = [testKind.setup, ...getSortedTests(testKind)]; 89 | 90 | return ( 91 | 92 | 93 | 94 | 95 |
102 | 103 | {isSearching ? ( 104 | this.renderSearchContent(sections) 105 | ) : ( 106 | <> 107 | 114 | {sectionName 115 | ? getSectionEl({ 116 | section: getSectionByName(sections, sectionName), 117 | testKind, 118 | searchText 119 | }) 120 | : sections.map(section => 121 | getSectionEl({ section, testKind, searchText }) 122 | )} 123 | 124 | )} 125 | 126 |