├── .gitignore ├── README.md ├── cypress.json ├── cypress ├── .eslintrc.json ├── .gitignore ├── global.d.ts ├── integration │ └── App.spec.js ├── plugins │ └── index.js └── support │ ├── commands.js │ └── index.js ├── jest-puppeteer.config.js ├── jest.config.js ├── package-lock.json ├── package.json ├── public ├── favicon.ico ├── index.html └── manifest.json ├── src ├── App.css ├── App.e2e.js ├── App.js ├── App.test.js ├── feedbackMachine.js ├── index.css ├── index.js ├── logo.svg └── serviceWorker.js └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Model-Based Testing with `@xstate/test` and React Demo 2 | 3 | This is a React project bootstrapped with [Create React App](https://github.com/facebook/create-react-app). It demonstrates how to use `@xstate/test` with React to automate the generation of integration and end-to-end (E2E) tests of an example application. 4 | 5 | ![End-to-end tests for Feedback app being run in a browser with Puppeteer](https://i.imgur.com/W5CaIIP.gif) 6 | 7 | ## Running the Tests 8 | 9 | To run the **integration tests**, run `npm test`. This will run the tests found in [`./src/App.test.js`](https://github.com/davidkpiano/xstate-test-demo/blob/master/src/App.test.js). 10 | 11 | To run the **E2E tests**, run `npm run e2e`. This will run the tests found in [`./src/App.e2e.js`](https://github.com/davidkpiano/xstate-test-demo/blob/master/src/App.e2e.js). 12 | 13 | NOTE: To run the **E2E tests** on a different port: `PORT=3001 npm run e2e` 14 | 15 | ## Resources 16 | 17 | - [Github: `@xstate/test`](https://github.com/davidkpiano/xstate/tree/master/packages/xstate-test) 18 | - [Slides: Write Less Tests! From Automation to Autogeneration](https://slides.com/davidkhourshid/mbt/) (React Rally 2019) 19 | - [Article: Model-Based Testing in React with State Machines](https://css-tricks.com/?p=286484) (CSS-Tricks) 20 | -------------------------------------------------------------------------------- /cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "chromeWebSecurity": false 3 | } -------------------------------------------------------------------------------- /cypress/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["plugin:cypress/recommended"] 3 | } 4 | -------------------------------------------------------------------------------- /cypress/.gitignore: -------------------------------------------------------------------------------- 1 | screenshots/* 2 | videos/* 3 | -------------------------------------------------------------------------------- /cypress/global.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare namespace Cypress { 4 | import { authService } from "../src/machines/authMachine"; 5 | import { createTransactionService } from "../src/machines/createTransactionMachine"; 6 | import { publicTransactionService } from "../src/machines/publicTransactionsMachine"; 7 | import { contactsTransactionService } from "../src/machines/contactsTransactionsMachine"; 8 | import { personalTransactionService } from "../src/machines/personalTransactionsMachine"; 9 | import { 10 | User, 11 | BankAccount, 12 | Like, 13 | Comment, 14 | Transaction, 15 | BankTransfer, 16 | Contact, 17 | } from "../src/models"; 18 | 19 | interface CustomWindow extends Window { 20 | authService: typeof authService; 21 | createTransactionService: typeof createTransactionService; 22 | publicTransactionService: typeof publicTransactionService; 23 | contactTransactionService: typeof contactsTransactionService; 24 | personalTransactionService: typeof personalTransactionService; 25 | } 26 | 27 | type dbQueryArg = { 28 | entity: string; 29 | query: object | [object]; 30 | }; 31 | 32 | interface Chainable { 33 | /** 34 | * Window object with additional properties used during test. 35 | */ 36 | window(options?: Partial): Chainable; 37 | 38 | /** 39 | * Custom command to make taking Percy snapshots with full name formed from the test title + suffix easier 40 | */ 41 | visualSnapshot(maybeName?): Chainable; 42 | 43 | getBySel(dataTestAttribute: string, args?: any): Chainable; 44 | getBySelLike(dataTestPrefixAttribute: string, args?: any): Chainable; 45 | 46 | /** 47 | * Cypress task for directly querying to the database within tests 48 | */ 49 | task( 50 | event: "filter:database", 51 | arg: dbQueryArg, 52 | options?: Partial 53 | ): Chainable; 54 | 55 | /** 56 | * Cypress task for directly querying to the database within tests 57 | */ 58 | task( 59 | event: "find:database", 60 | arg?: any, 61 | options?: Partial 62 | ): Chainable; 63 | 64 | /** 65 | * Find a single entity via database query 66 | */ 67 | database(operation: "find", entity: string, query?: object, log?: boolean): Chainable; 68 | 69 | /** 70 | * Filter for data entities via database query 71 | */ 72 | database(operation: "filter", entity: string, query?: object, log?: boolean): Chainable; 73 | 74 | /** 75 | * Fetch React component instance associated with received element subject 76 | */ 77 | reactComponent(): Chainable; 78 | 79 | /** 80 | * Select data range within date range picker component 81 | */ 82 | pickDateRange(startDate: Date, endDate: Date): Chainable; 83 | 84 | /** 85 | * Select transaction amount range 86 | */ 87 | setTransactionAmountRange(min: number, max: number): Chainable; 88 | 89 | /** 90 | * Paginate to the next page in transaction infinite-scroll pagination view 91 | */ 92 | nextTransactionFeedPage(service: string, page: number): Chainable; 93 | 94 | /** 95 | * Logs-in user by using UI 96 | */ 97 | login(username: string, password: string, rememberUser?: boolean): void; 98 | 99 | /** 100 | * Logs-in user by using API request 101 | */ 102 | loginByApi(username: string, password?: string): Chainable; 103 | 104 | /** 105 | * Logs in bypassing UI by triggering XState login event 106 | */ 107 | loginByXstate(username: string, password?: string): Chainable; 108 | 109 | /** 110 | * Logs out via bypassing UI by triggering XState logout event 111 | */ 112 | logoutByXstate(): Chainable; 113 | 114 | /** 115 | * Switch current user by logging out current user and logging as user with specified username 116 | */ 117 | switchUser(username: string): Chainable; 118 | 119 | /** 120 | * Create Transaction via bypassing UI and using XState createTransactionService 121 | */ 122 | createTransaction(payload): Chainable; 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /cypress/integration/App.spec.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { Machine } from "xstate"; 4 | import { createModel } from "@xstate/test"; 5 | 6 | const feedbackMachine = Machine({ 7 | id: "feedback", 8 | initial: "question", 9 | on: { 10 | ESC: "closed", 11 | }, 12 | states: { 13 | question: { 14 | on: { 15 | CLICK_GOOD: "thanks", 16 | CLICK_BAD: "form", 17 | CLOSE: "closed", 18 | }, 19 | meta: { 20 | test: function () { 21 | cy.get("[data-testid=question-screen]").contains( 22 | "How was your experience?" 23 | ); 24 | }, 25 | }, 26 | }, 27 | form: { 28 | on: { 29 | SUBMIT: [ 30 | { 31 | target: "thanks", 32 | cond: (_, e) => e.value.length, 33 | }, 34 | // This should probably target "closed", 35 | // but the demo app doesn't behave that way! 36 | { target: "thanks" }, 37 | ], 38 | CLOSE: "closed", 39 | }, 40 | meta: { 41 | test: function () { 42 | cy.get("[data-testid=form-screen]").contains("Care to tell us why?"); 43 | }, 44 | }, 45 | }, 46 | thanks: { 47 | on: { 48 | CLOSE: "closed", 49 | }, 50 | meta: { 51 | test: function () { 52 | cy.get("[data-testid=thanks-screen]").contains( 53 | "Thanks for your feedback." 54 | ); 55 | }, 56 | }, 57 | }, 58 | closed: { 59 | type: "final", 60 | meta: { 61 | test: function () { 62 | cy.get("[data-testid=question-screen]").should("not.exist"); 63 | cy.get("[data-testid=form-screen]").should("not.exist"); 64 | cy.get("[data-testid=thanks-screen]").should("not.exist"); 65 | }, 66 | }, 67 | }, 68 | }, 69 | }); 70 | 71 | const testModel = createModel(feedbackMachine, { 72 | events: { 73 | CLICK_GOOD: function () { 74 | cy.get("[data-testid=good-button]").click(); 75 | }, 76 | CLICK_BAD: function () { 77 | cy.get("[data-testid=bad-button]").click(); 78 | }, 79 | CLOSE: function () { 80 | cy.get("[data-testid=close-button]").click(); 81 | }, 82 | ESC: function () { 83 | cy.get("body").type("{esc}"); 84 | // And do this once again to avoid some occasional flake... 85 | cy.get("body").type("{esc}"); 86 | }, 87 | SUBMIT: { 88 | exec: function (_, event) { 89 | if (event.value?.length) 90 | cy.get("[data-testid=response-input]").type(event.value); 91 | cy.get("[data-testid=submit-button]").click(); 92 | }, 93 | cases: [{ value: "something" }, { value: "" }], 94 | }, 95 | }, 96 | }); 97 | 98 | const itVisitsAndRunsPathTests = (url) => (path) => 99 | it(path.description, function () { 100 | cy.visit(url).then(path.test); 101 | }); 102 | 103 | const itTests = itVisitsAndRunsPathTests( 104 | `http://localhost:${process.env.PORT || "3000"}` 105 | ); 106 | 107 | context("Feedback App", () => { 108 | const testPlans = testModel.getSimplePathPlans(); 109 | testPlans.forEach((plan) => { 110 | describe(plan.description, () => { 111 | plan.paths.forEach(itTests); 112 | }); 113 | }); 114 | }); 115 | -------------------------------------------------------------------------------- /cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | /// 2 | // *********************************************************** 3 | // This example plugins/index.js can be used to load plugins 4 | // 5 | // You can change the location of this file or turn off loading 6 | // the plugins file with the 'pluginsFile' configuration option. 7 | // 8 | // You can read more here: 9 | // https://on.cypress.io/plugins-guide 10 | // *********************************************************** 11 | 12 | // This function is called when a project is opened or re-opened (e.g. due to 13 | // the project's config changing) 14 | 15 | /** 16 | * @type {Cypress.PluginConfig} 17 | */ 18 | module.exports = (on, config) => { 19 | // `on` is used to hook into various events Cypress emits 20 | // `config` is the resolved Cypress config 21 | } 22 | -------------------------------------------------------------------------------- /cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add("login", (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This will overwrite an existing command -- 25 | // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) 26 | -------------------------------------------------------------------------------- /cypress/support/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /jest-puppeteer.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | server: { 3 | command: `npm start`, 4 | port: 3000, 5 | launchTimeout: 5000 6 | }, 7 | launch: { 8 | headless: false, 9 | slowMo: 50 10 | } 11 | }; 12 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'jest-puppeteer', 3 | testRegex: './*\\.e2e\\.js$' 4 | }; 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "feedback", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@xstate/graph": "0.0.1", 7 | "@xstate/react": "^0.1.0", 8 | "react": "^16.9.0", 9 | "react-dom": "^16.9.0", 10 | "react-scripts": "^2.1.8", 11 | "styled-components": "^4.3.2", 12 | "xstate": "^4.16.2" 13 | }, 14 | "scripts": { 15 | "start": "react-scripts start", 16 | "build": "react-scripts build", 17 | "test": "react-scripts test", 18 | "eject": "react-scripts eject", 19 | "e2e": "jest -c jest.config.js --verbose", 20 | "cypress": "cypress run", 21 | "cypress:open": "cypress open", 22 | "cypress:chrome": "cypress open --browser chrome" 23 | }, 24 | "eslintConfig": { 25 | "extends": "react-app" 26 | }, 27 | "browserslist": [ 28 | ">0.2%", 29 | "not dead", 30 | "not ie <= 11", 31 | "not op_mini all" 32 | ], 33 | "devDependencies": { 34 | "@testing-library/react": "^9.1.3", 35 | "@xstate/test": "^0.4.2", 36 | "chai": "^4.2.0", 37 | "cypress": "^6.5.0", 38 | "eslint-plugin-cypress": "^2.11.2", 39 | "jest-puppeteer": "^4.3.0", 40 | "puppeteer": "^5.3.1" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidkpiano/xstate-test-demo/be018908f24f61c6898f74065c20233225f190e6/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | 15 | 16 | 25 | React App 26 | 27 | 28 | 29 |
30 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | animation: App-logo-spin infinite 20s linear; 7 | height: 40vmin; 8 | } 9 | 10 | .App-header { 11 | background-color: #282c34; 12 | min-height: 100vh; 13 | display: flex; 14 | flex-direction: column; 15 | align-items: center; 16 | justify-content: center; 17 | font-size: calc(10px + 2vmin); 18 | color: white; 19 | } 20 | 21 | .App-link { 22 | color: #61dafb; 23 | } 24 | 25 | @keyframes App-logo-spin { 26 | from { 27 | transform: rotate(0deg); 28 | } 29 | to { 30 | transform: rotate(360deg); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/App.e2e.js: -------------------------------------------------------------------------------- 1 | const { Machine } = require('xstate'); 2 | const { createModel } = require('@xstate/test'); 3 | 4 | describe('feedback app', () => { 5 | const feedbackMachine = Machine({ 6 | id: 'feedback', 7 | initial: 'question', 8 | states: { 9 | question: { 10 | on: { 11 | CLICK_GOOD: 'thanks', 12 | CLICK_BAD: 'form', 13 | CLOSE: 'closed' 14 | }, 15 | meta: { 16 | test: async page => { 17 | await page.waitFor('[data-testid="question-screen"]'); 18 | } 19 | } 20 | }, 21 | form: { 22 | on: { 23 | SUBMIT: [ 24 | { 25 | target: 'thanks', 26 | cond: (_, e) => e.value.length 27 | } 28 | ], 29 | CLOSE: 'closed' 30 | }, 31 | meta: { 32 | test: async page => { 33 | await page.waitFor('[data-testid="form-screen"]'); 34 | } 35 | } 36 | }, 37 | thanks: { 38 | on: { 39 | CLOSE: 'closed' 40 | }, 41 | meta: { 42 | test: async page => { 43 | await page.waitFor('[data-testid="thanks-screen"]'); 44 | } 45 | } 46 | }, 47 | closed: { 48 | type: 'final', 49 | meta: { 50 | test: async page => { 51 | return true; 52 | } 53 | } 54 | } 55 | } 56 | }); 57 | 58 | const testModel = createModel(feedbackMachine, { 59 | events: { 60 | CLICK_GOOD: async page => { 61 | await page.click('[data-testid="good-button"]'); 62 | }, 63 | CLICK_BAD: async page => { 64 | await page.click('[data-testid="bad-button"]'); 65 | }, 66 | CLOSE: async page => { 67 | await page.click('[data-testid="close-button"]'); 68 | }, 69 | ESC: async page => { 70 | await page.press('Escape'); 71 | }, 72 | SUBMIT: { 73 | exec: async (page, event) => { 74 | await page.type('[data-testid="response-input"]', event.value); 75 | await page.click('[data-testid="submit-button"]'); 76 | }, 77 | cases: [{ value: 'something' }, { value: '' }] 78 | } 79 | } 80 | }); 81 | 82 | const testPlans = testModel.getSimplePathPlans(); 83 | 84 | testPlans.forEach((plan, i) => { 85 | describe(plan.description, () => { 86 | plan.paths.forEach((path, i) => { 87 | it( 88 | path.description, 89 | async () => { 90 | await page.goto(`http://localhost:${process.env.PORT || '3000'}`); 91 | await path.test(page); 92 | }, 93 | 10000 94 | ); 95 | }); 96 | }); 97 | }); 98 | 99 | it('coverage', () => { 100 | testModel.testCoverage(); 101 | }); 102 | }); 103 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import React, { useReducer, useEffect } from 'react'; 4 | import styled from 'styled-components'; 5 | 6 | function useKeyDown(key, onKeyDown) { 7 | useEffect(() => { 8 | const handler = e => { 9 | if (e.key === key) { 10 | onKeyDown(); 11 | } 12 | }; 13 | 14 | window.addEventListener('keydown', handler); 15 | 16 | return () => window.removeEventListener('keydown', handler); 17 | }, [onKeyDown]); 18 | } 19 | 20 | const StyledScreen = styled.div` 21 | padding: 1rem; 22 | padding-top: 2rem; 23 | background: white; 24 | box-shadow: 0 1rem 2rem rgba(0, 0, 0, 0.1); 25 | border-radius: 0.5rem; 26 | 27 | > header { 28 | margin-bottom: 1rem; 29 | } 30 | 31 | button { 32 | background: #4088da; 33 | appearance: none; 34 | border: none; 35 | text-transform: uppercase; 36 | color: white; 37 | letter-spacing: 0.5px; 38 | font-weight: bold; 39 | padding: 0.5rem 1rem; 40 | border-radius: 20rem; 41 | align-self: flex-end; 42 | cursor: pointer; 43 | font-size: 0.75rem; 44 | 45 | + button { 46 | margin-left: 0.5rem; 47 | } 48 | 49 | &[data-variant='good'] { 50 | background-color: #7cbd67; 51 | } 52 | &[data-variant='bad'] { 53 | background-color: #ff4652; 54 | } 55 | } 56 | 57 | textarea { 58 | display: block; 59 | margin-bottom: 1rem; 60 | border: 1px solid #dedede; 61 | font-size: 1rem; 62 | } 63 | 64 | [data-testid='close-button'] { 65 | position: absolute; 66 | top: 0; 67 | right: 0; 68 | appearance: none; 69 | height: 2rem; 70 | width: 2rem; 71 | line-height: 0; 72 | border: none; 73 | background: transparent; 74 | text-align: center; 75 | display: flex; 76 | justify-content: center; 77 | align-items: center; 78 | 79 | &:before { 80 | content: '×'; 81 | font-size: 1.5rem; 82 | color: rgba(0, 0, 0, 0.5); 83 | } 84 | } 85 | `; 86 | 87 | function QuestionScreen({ onClickGood, onClickBad, onClose }) { 88 | useKeyDown('Escape', onClose); 89 | 90 | return ( 91 | 92 |
How was your experience?
93 | 100 | 103 |