├── README.old.md ├── .vscode ├── settings.json └── tasks.json ├── cypress ├── fixtures │ └── authentication │ │ └── authentication-success.json ├── plugins │ └── index.js ├── support │ ├── index.js │ └── commands.js └── integration │ └── authentication │ ├── authentication.e2e.test.js │ └── authentication.integration.test.js ├── .eslintrc ├── assets └── header.jpg ├── public ├── favicon.ico ├── manifest.json └── index.html ├── cypress.json ├── src ├── constants.js ├── App.test.js ├── strings.js ├── index.css ├── index.js ├── App.css ├── App.js ├── logo.svg ├── serviceWorker.js └── logo-ws.svg ├── working-software-mastering-ui-testing.code-workspace ├── .gitignore ├── package.json ├── server └── index.js └── README.md /README.old.md: -------------------------------------------------------------------------------- 1 | # working-software-mastering-ui-testing -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true 3 | } 4 | -------------------------------------------------------------------------------- /cypress/fixtures/authentication/authentication-success.json: -------------------------------------------------------------------------------- 1 | { 2 | "token": "xxxxxxxxxxxxxxxxxx" 3 | } 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "globals": { 3 | "cy": true 4 | }, 5 | "parser": "babel-eslint" 6 | } 7 | -------------------------------------------------------------------------------- /assets/header.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NoriSte/working-software-mastering-ui-testing/HEAD/assets/header.jpg -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NoriSte/working-software-mastering-ui-testing/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseUrl": "http://localhost:3000/", 3 | "cypress-watch-and-reload": { 4 | "watch": "src/*" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | export const SERVER_URL = "http://localhost:3001"; 2 | export const AUTHENTICATE_API_URL = "/api/authentication"; 3 | -------------------------------------------------------------------------------- /src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | 5 | it('renders without crashing', () => { 6 | const div = document.createElement('div'); 7 | ReactDOM.render(, div); 8 | ReactDOM.unmountComponentAtNode(div); 9 | }); 10 | -------------------------------------------------------------------------------- /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/strings.js: -------------------------------------------------------------------------------- 1 | export const USERNAME_PLACEHOLDER = "Your username"; 2 | export const PASSWORD_PLACEHOLDER = "Your password"; 3 | export const LOGIN_BUTTON = "Login"; 4 | export const LOADING = "Loading"; 5 | export const LONG_WAITING = "Be patient..."; 6 | export const SUCCESS_FEEDBACK = "Welcome back!"; 7 | export const GENERIC_ERROR = "An error occured, please retry"; 8 | export const UNAUTHORIZED_ERROR = "The credentials are wrong"; 9 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", 4 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /working-software-mastering-ui-testing.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "." 5 | } 6 | ], 7 | "settings": { 8 | "workbench.colorCustomizations": { 9 | "editor.background": "#000", 10 | "titleBar.activeBackground": "#4df75b", 11 | "titleBar.inactiveBackground": "#4df75b99", 12 | "titleBar.activeForeground": "#15202b", 13 | "titleBar.inactiveForeground": "#15202b99" 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.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 | 25 | *.mp4 26 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import App from './App'; 5 | import * as serviceWorker from './serviceWorker'; 6 | 7 | ReactDOM.render(, document.getElementById('root')); 8 | 9 | // If you want your app to work offline and load faster, you can change 10 | // unregister() to register() below. Note this comes with some pitfalls. 11 | // Learn more about service workers: https://bit.ly/CRA-PWA 12 | serviceWorker.unregister(); 13 | -------------------------------------------------------------------------------- /cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example plugins/index.js can be used to load plugins 3 | // 4 | // You can change the location of this file or turn off loading 5 | // the plugins file with the 'pluginsFile' configuration option. 6 | // 7 | // You can read more here: 8 | // https://on.cypress.io/plugins-guide 9 | // *********************************************************** 10 | 11 | // This function is called when a project is opened or re-opened (e.g. due to 12 | // the project's config changing) 13 | require("cypress-watch-and-reload/plugins"); 14 | const task = require("cypress-skip-and-only-ui/task"); 15 | 16 | module.exports = (on, config) => { 17 | on("task", task); 18 | }; 19 | -------------------------------------------------------------------------------- /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 | 22 | // relaunch the tests as soon as you save your source files 23 | require("cypress-watch-and-reload/support"); 24 | // allows you to skip (or run only) some tests directly from the Cypress UI 25 | require("cypress-skip-and-only-ui/support"); 26 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "label": "Starts the front-end app", 8 | "type": "shell", 9 | "command": "npm run start", 10 | "group": "test", 11 | "presentation": { 12 | "reveal": "always", 13 | "panel": "dedicated" 14 | }, 15 | "auto": true 16 | }, 17 | { 18 | "label": "Starts the back-end app", 19 | "type": "shell", 20 | "command": "npm run start:server", 21 | "group": "test", 22 | "presentation": { 23 | "reveal": "always", 24 | "panel": "dedicated" 25 | }, 26 | "auto": true 27 | }, 28 | { 29 | "label": "Open Cypress", 30 | "type": "shell", 31 | "command": "npm run cy:open", 32 | "group": "test", 33 | "presentation": { 34 | "reveal": "always", 35 | "panel": "dedicated" 36 | }, 37 | "auto": true 38 | } 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /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 is will overwrite an existing command -- 25 | // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) 26 | 27 | // a series of functions useful to test the front-end app the same way the user consumes it 28 | import "@testing-library/cypress/add-commands"; 29 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | @import url("https://fonts.googleapis.com/css?family=Lato&display=swap"); 2 | 3 | .App { 4 | text-align: center; 5 | font-size: 2rem; 6 | font-family: "Lato", sans-serif; 7 | } 8 | 9 | span, 10 | input { 11 | display: block; 12 | } 13 | input, 14 | input::placeholder { 15 | font-size: 1rem; 16 | } 17 | 18 | button { 19 | font-size: 1.3rem; 20 | } 21 | 22 | .App-logo { 23 | height: 30vmin; 24 | max-height: 80px; 25 | pointer-events: none; 26 | margin: 20px; 27 | } 28 | 29 | .App-header { 30 | background-color: #6bce85; 31 | max-height: 150px; 32 | display: flex; 33 | flex-direction: column; 34 | align-items: center; 35 | justify-content: center; 36 | font-size: calc(10px + 2vmin); 37 | color: white; 38 | } 39 | .App-header-ws { 40 | background-color: #2e2e37; 41 | } 42 | 43 | .App-link { 44 | color: #ffe300; 45 | } 46 | 47 | .App-body { 48 | padding: 10%; 49 | } 50 | .App-body > * { 51 | width: 100%; 52 | } 53 | .App-body > * + * { 54 | margin-top: 1em; 55 | } 56 | 57 | button { 58 | background: #f36; 59 | color: #fff; 60 | border: none; 61 | text-transform: uppercase; 62 | font-weight: bold; 63 | border-radius: 0.3em; 64 | } 65 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "working-software-mastering-ui-testing", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "start": "react-scripts start", 7 | "build": "react-scripts build", 8 | "test": "npx start-server-and-test start:server :3001 start:client", 9 | "eject": "react-scripts eject", 10 | "// SERVER /////////": "", 11 | "start:server": "nodemon server/index.js", 12 | "// CYPRESS /////////": "", 13 | "start:client": "npx start-server-and-test start :3000 cy:test", 14 | "cy:open": "cypress open", 15 | "cy:test": "cypress run", 16 | "cy:test:integration": "cypress run --spec \"cypress/**/*.integration.*\"", 17 | "cy:test:e2e": "cypress run --spec \"cypress/**/*.e2e.*\"" 18 | }, 19 | "eslintConfig": { 20 | "extends": "react-app" 21 | }, 22 | "browserslist": { 23 | "production": [ 24 | ">0.2%", 25 | "not dead", 26 | "not op_mini all" 27 | ], 28 | "development": [ 29 | "last 1 chrome version", 30 | "last 1 firefox version", 31 | "last 1 safari version" 32 | ] 33 | }, 34 | "dependencies": { 35 | "axios": "0.21.0", 36 | "cors": "2.8.5", 37 | "express": "4.17.1", 38 | "react": "17.0.1", 39 | "react-dom": "17.0.1", 40 | "react-scripts": "4.0.0" 41 | }, 42 | "devDependencies": { 43 | "@cypress/browserify-preprocessor": "3.0.1", 44 | "@testing-library/cypress": "4.2.0", 45 | "cypress": "5.6.0", 46 | "cypress-skip-and-only-ui": "1.2.10", 47 | "cypress-watch-and-reload": "1.2.18", 48 | "nodemon": "2.0.6", 49 | "start-server-and-test": "1.11.5" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 22 | React App 23 | 24 | 25 | 26 |
27 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | /** this is a fake server */ 2 | 3 | var express = require("express"); 4 | var cors = require("cors"); 5 | var app = express(); 6 | 7 | app.use(express.json()); 8 | app.use( 9 | cors({ 10 | origin: "*" 11 | }) 12 | ); 13 | 14 | app.get("/", function(req, res) { 15 | // just to have start-server-and-test working 16 | res.send(); 17 | }); 18 | 19 | app.post("/api/authentication", function(req, res) { 20 | const { username, password } = req.body; 21 | const user = users.find(item => item.username === username); 22 | if (users && user && user.password === password) { 23 | // just to simulate E2E tests slowness// @see 24 | // https://slides.com/noriste/working-software-2019-mastering-ui-testing#ui-integration-tests 25 | setTimeout(() => res.send({ token: Date.now() }), 1000 + Math.random() * 2000); 26 | } else { 27 | res.status(401); 28 | res.send({}); 29 | } 30 | }); 31 | 32 | let users = []; 33 | app.post("/e2e-tests/seed-data", function(req, res) { 34 | // a utility to add fake users for E2E tests 35 | users.push({ 36 | username: req.body.username, 37 | password: req.body.password 38 | }); 39 | 40 | // just to simulate E2E tests slowness 41 | // https://slides.com/noriste/working-software-2019-mastering-ui-testing#ui-integration-tests 42 | setTimeout(() => res.send({}), 1000 + Math.random() * 2000); 43 | }); 44 | 45 | app.post("/e2e-tests/wipe-data", function(req, res) { 46 | // a utility to wipe the fake users data for E2E tests 47 | users = []; 48 | 49 | // just to simulate E2E tests slowness 50 | // https://slides.com/noriste/working-software-2019-mastering-ui-testing#ui-integration-tests 51 | setTimeout(() => res.send({}), 1000 + Math.random() * 2000); 52 | }); 53 | 54 | app.listen(3001); 55 | -------------------------------------------------------------------------------- /cypress/integration/authentication/authentication.e2e.test.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { AUTHENTICATE_API_URL, SERVER_URL } from "../../../src/constants"; 4 | import { 5 | LOGIN_BUTTON, 6 | PASSWORD_PLACEHOLDER, 7 | SUCCESS_FEEDBACK, 8 | USERNAME_PLACEHOLDER 9 | } from "../../../src/strings"; 10 | 11 | context("Authentication", () => { 12 | const username = "stefano@conio.com"; 13 | const password = "mysupersecretpassword"; 14 | 15 | // the presence of database data is one of the things that make E2E tests less practical 16 | before(() => { 17 | // E2E tests need to have credible data. Always wipe the previous tests data BEFORE the test 18 | // because to avoid the possibility of test failure because of not-ready data 19 | cy.request("POST", `${SERVER_URL}/e2e-tests/wipe-data`, { 20 | username, 21 | password 22 | }); 23 | 24 | // E2E tests need to have credible data. 25 | cy.request("POST", `${SERVER_URL}/e2e-tests/seed-data`, { 26 | username, 27 | password 28 | }); 29 | }); 30 | 31 | beforeEach(() => { 32 | // just to leave more space to the Cypress test runner 33 | cy.viewport(300, 600); 34 | 35 | // cy.server() allows you to intercept (and wait for) every fronte-end AJAX request 36 | // @see https://docs.cypress.io/api/commands/server.html 37 | cy.server(); 38 | 39 | // visit a relative url, see the `cypress.json` file where the baseUrl is set 40 | // @see https://docs.cypress.io/api/commands/visit.html#Syntax 41 | cy.visit("/"); 42 | }); 43 | 44 | // this is a copy of the integration test but without server stubbing. 45 | // Remember to write a few E2E tests and a lot of integration ones 46 | // @see https://slides.com/noriste/working-software-2019-mastering-ui-testing#ui-integration-tests 47 | it("should work with the right credentials", () => { 48 | // intercepts every auth AJAX request 49 | cy.route({ 50 | method: "POST", 51 | url: `**${AUTHENTICATE_API_URL}` 52 | }).as("auth-xhr"); 53 | 54 | cy.getByPlaceholderText(USERNAME_PLACEHOLDER) 55 | .should("be.visible") 56 | .type(username); 57 | cy.getByPlaceholderText(PASSWORD_PLACEHOLDER) 58 | .should("be.visible") 59 | .type(password); 60 | cy.getByText(LOGIN_BUTTON) 61 | .should("be.visible") 62 | .click(); 63 | 64 | cy.wait("@auth-xhr").then(xhr => { 65 | expect(xhr.request.body).to.have.property("username", username); 66 | expect(xhr.request.body).to.have.property("password", password); 67 | // since the integration tests already tested the front-end app, we use E2E tests to check the 68 | // back-end app. It needs to ensure that the back-end app works and gets the correct response 69 | // data 70 | // @see https://slides.com/noriste/working-software-2019-mastering-ui-testing#backend-contract 71 | expect(xhr.status).to.equal(200); 72 | expect(xhr.response.body).to.have.property("token"); 73 | }); 74 | 75 | cy.getByText(SUCCESS_FEEDBACK).should("be.visible"); 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import Axios from "axios"; 2 | import React from "react"; 3 | import "./App.css"; 4 | import { AUTHENTICATE_API_URL, SERVER_URL } from "./constants"; 5 | import logoWS from "./logo-ws.svg"; 6 | import logo from "./logo.svg"; 7 | import { 8 | GENERIC_ERROR, 9 | LOADING, 10 | LOGIN_BUTTON, 11 | LONG_WAITING, 12 | PASSWORD_PLACEHOLDER, 13 | SUCCESS_FEEDBACK, 14 | UNAUTHORIZED_ERROR, 15 | USERNAME_PLACEHOLDER 16 | } from "./strings"; 17 | 18 | /** 19 | * This app is far from being a well-written React app, its sole purpose is to allow me showing 20 | * some e2e testing characteristics 21 | */ 22 | function App() { 23 | // the controlled input states 24 | const [username, setUsername] = React.useState(""); 25 | const [password, setPassword] = React.useState(""); 26 | // the AJAX state 27 | const [loading, setLoading] = React.useState(false); 28 | const [error, setError] = React.useState(""); 29 | const [success, setSuccess] = React.useState(false); 30 | const [longWaiting, setLongWaiting] = React.useState(false); 31 | 32 | const authenticate = async (usr, pwd) => { 33 | setLoading(true); 34 | let response; 35 | // in case of big loading duration, it changes the user feedback 36 | const timeoutId = setTimeout(() => setLongWaiting(true), 1000); 37 | try { 38 | // the real AJAX call 39 | response = await Axios.post(SERVER_URL + AUTHENTICATE_API_URL, { 40 | username: usr, 41 | password: pwd 42 | }); 43 | } catch (e) { 44 | // 401 error means "unauthorized", it manages all the other error states as "generic" errors 45 | setError(e.response && e.response.status === 401 ? UNAUTHORIZED_ERROR : GENERIC_ERROR); 46 | setSuccess(false); 47 | } 48 | setLoading(false); 49 | setLongWaiting(false); 50 | clearTimeout(timeoutId); 51 | 52 | // success management 53 | if (response && response.status === 200) { 54 | setSuccess(true); 55 | } 56 | }; 57 | 58 | // Always add some shortcuts letting the UI test to run faster 59 | // @see https://slides.com/noriste/working-software-2019-mastering-ui-testing#test-shortcuts 60 | if (window.Cypress) { 61 | window.cypressShortcuts = { 62 | authenticate 63 | }; 64 | } 65 | 66 | return ( 67 |
68 | {/* some companies to thank 😊 */} 69 |
70 | logo 71 |
72 |
73 | logo 74 |
75 | {/* the app content to be tested */} 76 |
77 | Please type 78 | setUsername(e.target.value)} 82 | /> 83 | setPassword(e.target.value)} 88 | /> 89 | 90 | {/* AJAX loading feedbacks */} 91 | 92 | {loading && LOADING} 93 | {success && SUCCESS_FEEDBACK} 94 | {error} 95 | 96 | {longWaiting && LONG_WAITING} 97 |
98 |
99 | ); 100 | } 101 | 102 | export default App; 103 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Mastering UI Testing 2 | 3 | [![Open Source Love](https://badges.frapsoft.com/os/mit/mit.svg?v=102)](https://github.com/ellerbrock/open-source-badge/) 4 | 5 | - [Goal of this repository](#Goal-of-this-repository) 6 | - [About this repository](#About-this-repository) 7 | - [How to play with it](#How-to-play-with-it) 8 | - [How to read it](#How-to-read-it) 9 | - [UI Testing Best Practices book](#UI-Testing-Best-Practices-book) 10 | - [Notes for the talk](#Notes-for-the-talk) 11 | 12 |
13 | 14 | [![Mastering UI Testing](assets/header.jpg)](https://www.agilemovement.it/workingsoftware/) 15 | 16 | You can find the slides of the talk [here](https://slides.com/noriste/working-software-2019-mastering-ui-testing). 17 | 18 | ## Goal of this repository 19 | 20 | I made this repository to follow up with the best practices I highlighted during my talk at the 21 | [Working Software conference](https://www.agilemovement.it/workingsoftware/). 22 | 23 | ## About this repository 24 | 25 | - I bootstrapped this project with [create-react-app](https://facebook.github.io/create-react-app/docs/getting-started) 26 | - it contains a super-simple authentication form 27 | - it contains a fake server with artificial delays to simulate E2E testing slowness 28 | - it runs the tests in Travis too to show a complete UI Testing project 29 | - all the code is well commented, with a lot of links to the slide explanations 30 | - I wrote the front-end app with a outside-in approach writing the acceptance test at the beginning. 31 | I have not tested it manually at all! Remember to use your [testing tool as your primary development tool](https://slides.com/noriste/working-software-2019-mastering-ui-testing#testing-tool-as-development-tool) 32 | - the `talk` branch is helpful only for the day of the conference, do not consider it 33 | 34 | ## How to play with it 35 | 36 | There are four main commands: 37 | 38 | - `npm run start`: starts the (super simple) front-end app 39 | - `npm run start:server`: starts the (fake) back-end app 40 | - `npm run cy:open`: opens the Cypress UI 41 | - `npm test`: launches both the front-end and the back-end apps, and runs cypress in the non-visual 42 | mode. Remember killing the manually launched apps since it uses the same ports 43 | 44 | Please note: if you have the [Autolaunch 45 | extension](https://marketplace.visualstudio.com/items?itemName=philfontaine.autolaunch) for VS Code, 46 | it proposes you to launch these scripts automatically. 47 | 48 | ## How to read it 49 | 50 | - read the [slides of the talk](https://slides.com/noriste/working-software-2019-mastering-ui-testing) 51 | - launch the front-end app and take a look at the `src/App.js` file 52 | - launch both the back-end app and Cypress 53 | - launch the `authentication.integration.test.js` in Cypress and watch it running 54 | - open the `cypress/integration/authentication.integration.test.js` and explore it 55 | - then, move to the `cypress/integration/authentication.e2e.test.js` 56 | - in the end: run the `npm test` command 57 | 58 | ## UI Testing Best Practices book 59 | 60 | Do not forge to add a star to my (work in progress) [UI Testing Best 61 | Practices](https://github.com/NoriSte/ui-testing-best-practices) book on GitHub 😊 62 | 63 | ## Notes for the talk 64 | 65 | - checkout the `talk` branch 66 | - launch all the scripts except for `npx cypress open` 67 | - you will launch `npx cypress open` as soon as you start showing the code at the talk 68 | - show cypress and VSCode side-by-side on the same screen 69 | - prepare the browser opened on the slides 70 | - if you need, take a look at the `transcription.md` file on the `talk` branch 71 | - take a look at the ["How to Talk to Developers"](https://www.youtube.com/watch?v=l9JXH7JPjR4) talk by Ben Orenstein 72 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | Group 2 -------------------------------------------------------------------------------- /src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.1/8 is considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | export function register(config) { 24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 25 | // The URL constructor is available in all browsers that support SW. 26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 27 | if (publicUrl.origin !== window.location.origin) { 28 | // Our service worker won't work if PUBLIC_URL is on a different origin 29 | // from what our page is served on. This might happen if a CDN is used to 30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 31 | return; 32 | } 33 | 34 | window.addEventListener('load', () => { 35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 36 | 37 | if (isLocalhost) { 38 | // This is running on localhost. Let's check if a service worker still exists or not. 39 | checkValidServiceWorker(swUrl, config); 40 | 41 | // Add some additional logging to localhost, pointing developers to the 42 | // service worker/PWA documentation. 43 | navigator.serviceWorker.ready.then(() => { 44 | console.log( 45 | 'This web app is being served cache-first by a service ' + 46 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 47 | ); 48 | }); 49 | } else { 50 | // Is not localhost. Just register service worker 51 | registerValidSW(swUrl, config); 52 | } 53 | }); 54 | } 55 | } 56 | 57 | function registerValidSW(swUrl, config) { 58 | navigator.serviceWorker 59 | .register(swUrl) 60 | .then(registration => { 61 | registration.onupdatefound = () => { 62 | const installingWorker = registration.installing; 63 | if (installingWorker == null) { 64 | return; 65 | } 66 | installingWorker.onstatechange = () => { 67 | if (installingWorker.state === 'installed') { 68 | if (navigator.serviceWorker.controller) { 69 | // At this point, the updated precached content has been fetched, 70 | // but the previous service worker will still serve the older 71 | // content until all client tabs are closed. 72 | console.log( 73 | 'New content is available and will be used when all ' + 74 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 75 | ); 76 | 77 | // Execute callback 78 | if (config && config.onUpdate) { 79 | config.onUpdate(registration); 80 | } 81 | } else { 82 | // At this point, everything has been precached. 83 | // It's the perfect time to display a 84 | // "Content is cached for offline use." message. 85 | console.log('Content is cached for offline use.'); 86 | 87 | // Execute callback 88 | if (config && config.onSuccess) { 89 | config.onSuccess(registration); 90 | } 91 | } 92 | } 93 | }; 94 | }; 95 | }) 96 | .catch(error => { 97 | console.error('Error during service worker registration:', error); 98 | }); 99 | } 100 | 101 | function checkValidServiceWorker(swUrl, config) { 102 | // Check if the service worker can be found. If it can't reload the page. 103 | fetch(swUrl) 104 | .then(response => { 105 | // Ensure service worker exists, and that we really are getting a JS file. 106 | const contentType = response.headers.get('content-type'); 107 | if ( 108 | response.status === 404 || 109 | (contentType != null && contentType.indexOf('javascript') === -1) 110 | ) { 111 | // No service worker found. Probably a different app. Reload the page. 112 | navigator.serviceWorker.ready.then(registration => { 113 | registration.unregister().then(() => { 114 | window.location.reload(); 115 | }); 116 | }); 117 | } else { 118 | // Service worker found. Proceed as normal. 119 | registerValidSW(swUrl, config); 120 | } 121 | }) 122 | .catch(() => { 123 | console.log( 124 | 'No internet connection found. App is running in offline mode.' 125 | ); 126 | }); 127 | } 128 | 129 | export function unregister() { 130 | if ('serviceWorker' in navigator) { 131 | navigator.serviceWorker.ready.then(registration => { 132 | registration.unregister(); 133 | }); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /cypress/integration/authentication/authentication.integration.test.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { AUTHENTICATE_API_URL } from "../../../src/constants"; 4 | // all the app strings are imported, they allow us to test the front-end app like the user is going 5 | // to consume it (through contents, not through selectors) 6 | // @see https://slides.com/noriste/working-software-2019-mastering-ui-testing#test-through-contents 7 | // @see https://slides.com/noriste/working-software-2019-mastering-ui-testing#frontend-contants 8 | import { 9 | GENERIC_ERROR, 10 | LOADING, 11 | LOGIN_BUTTON, 12 | LONG_WAITING, 13 | PASSWORD_PLACEHOLDER, 14 | SUCCESS_FEEDBACK, 15 | UNAUTHORIZED_ERROR, 16 | USERNAME_PLACEHOLDER 17 | } from "../../../src/strings"; 18 | 19 | context("Authentication", () => { 20 | beforeEach(() => { 21 | // just to leave more space to the Cypress test runner 22 | cy.viewport(300, 600); 23 | 24 | // cy.server() allows you to intercept (and wait for) every fronte-end AJAX request 25 | // @see https://docs.cypress.io/api/commands/server.html 26 | cy.server(); 27 | 28 | // visit a relative url, see the `cypress.json` file where the baseUrl is set 29 | // @see https://docs.cypress.io/api/commands/visit.html#Syntax 30 | cy.visit("/"); 31 | }); 32 | 33 | const username = "stefano@conio.com"; 34 | const password = "mysupersecretpassword"; 35 | 36 | it("should work with the right credentials", () => { 37 | // intercepts every auth AJAX request and responds with the content of the 38 | // authentication-success.json fixture. This is called server stubbing 39 | cy.route({ 40 | method: "POST", 41 | response: "fixture:authentication/authentication-success.json", 42 | url: `**${AUTHENTICATE_API_URL}` 43 | }).as("auth-xhr"); 44 | 45 | // retrieves the elements to interact with by contents, the same way the user would do so 46 | cy.getByPlaceholderText(USERNAME_PLACEHOLDER) 47 | // in case of failures, a lot of assertions drive you directly to the exact problem that 48 | // occured, making test debugging useless 49 | // @see https://slides.com/noriste/working-software-2019-mastering-ui-testing#assert-frequently 50 | .should("be.visible") 51 | .type(username); 52 | cy.getByPlaceholderText(PASSWORD_PLACEHOLDER) 53 | .should("be.visible") // assertions FTW 54 | .type(password); 55 | cy.getByText(LOGIN_BUTTON) 56 | .should("be.visible") // assertions FTW 57 | .click(); 58 | 59 | // the AJAX request is a deterministic event, it MUST happen for the front-end app to work! 60 | // Asserting on deterministic events make your test more robust 61 | // @see https://slides.com/noriste/working-software-2019-mastering-ui-testing#deterministic-events 62 | cy.wait("@auth-xhr").then(xhr => { 63 | // a lot of times the front-end app does not work because of wrong communication with the 64 | // back-end app, always assert on the request payload 65 | // @see https://slides.com/noriste/working-software-2019-mastering-ui-testing#backend-contract 66 | expect(xhr.request.body).to.have.property("username", username); 67 | expect(xhr.request.body).to.have.property("password", password); 68 | }); 69 | 70 | // finally, the user must see the feedback 71 | cy.getByText(SUCCESS_FEEDBACK).should("be.visible"); 72 | }); 73 | 74 | // from now on, it will use a shared function to fill the form. 75 | // Remember always to add simple abstractions because, test by test, you always need to slightly 76 | // change the behavior to test every flow. 77 | const fillFormAndClick = ({ username, password }) => { 78 | cy.getByPlaceholderText(USERNAME_PLACEHOLDER) 79 | .should("be.visible") // assertions FTW 80 | .type(username); 81 | cy.getByPlaceholderText(PASSWORD_PLACEHOLDER) 82 | .should("be.visible") // assertions FTW 83 | .type(password); 84 | cy.getByText(LOGIN_BUTTON) 85 | .should("be.visible") // assertions FTW 86 | .click(); 87 | }; 88 | 89 | it("should alert the user it the login lasts long", () => { 90 | // it allows you to manage manually the front-end clock, see the `cy.tick` call 91 | cy.clock(); 92 | 93 | cy.route({ 94 | method: "POST", 95 | // the response is not useful for this test, it has to test the long-awaiting feedback, not 96 | // the feedback after the AJAX call completion 97 | response: {}, 98 | url: `**${AUTHENTICATE_API_URL}`, 99 | // adds a super-long delay to the AJAX response 100 | delay: 20000 101 | }).as("auth-xhr"); 102 | 103 | fillFormAndClick({ username, password }); 104 | 105 | // moves forward the front-end clock, it allows to manage to force `setTimeout` to happen in a while 106 | cy.tick(1000); 107 | 108 | cy.getByText(LOADING).should("be.visible"); 109 | cy.getByText(LONG_WAITING).should("be.visible"); 110 | }); 111 | 112 | it("should alert the user it the credentials are wrong", () => { 113 | // intercepts every auth AJAX request and responds with a 401 status 114 | cy.route({ 115 | method: "POST", 116 | response: {}, 117 | url: `**${AUTHENTICATE_API_URL}`, 118 | status: 401 119 | }).as("auth-xhr"); 120 | 121 | fillFormAndClick({ username, password }); 122 | 123 | cy.wait("@auth-xhr").then(xhr => { 124 | expect(xhr.request.body).to.have.property("username", username); 125 | expect(xhr.request.body).to.have.property("password", password); 126 | }); 127 | 128 | cy.getByText(UNAUTHORIZED_ERROR).should("be.visible"); 129 | }); 130 | 131 | it("should alert the user it the server does not work", () => { 132 | // intercepts every auth AJAX request and responds with a 500 status 133 | cy.route({ 134 | method: "POST", 135 | response: {}, 136 | url: `**${AUTHENTICATE_API_URL}`, 137 | status: 500 138 | }).as("auth-xhr"); 139 | 140 | fillFormAndClick({ username, password }); 141 | cy.getByText(LOGIN_BUTTON).click(); 142 | 143 | cy.getByText(GENERIC_ERROR).should("be.visible"); 144 | }); 145 | 146 | // Other tests must not waste time with authentication, always allows them to authenticate as fast 147 | // as they can, they will save precious seconds at every run. 148 | // @see https://slides.com/noriste/working-software-2019-mastering-ui-testing#test-shortcuts 149 | it("should expose a shortcut for fast authentication", () => { 150 | cy.route({ 151 | method: "POST", 152 | response: "fixture:authentication/authentication-success.json", 153 | url: `**${AUTHENTICATE_API_URL}` 154 | }).as("auth-xhr"); 155 | 156 | cy.window().invoke("cypressShortcuts.authenticate", username, password); 157 | 158 | cy.wait("@auth-xhr").then(xhr => { 159 | expect(xhr.request.body).to.have.property("username", username); 160 | expect(xhr.request.body).to.have.property("password", password); 161 | }); 162 | }); 163 | }); 164 | -------------------------------------------------------------------------------- /src/logo-ws.svg: -------------------------------------------------------------------------------- 1 | 2 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | --------------------------------------------------------------------------------