99 | );
100 | }
101 |
102 | export default App;
103 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | Mastering UI Testing
2 |
3 | [](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 | [](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 |
--------------------------------------------------------------------------------
/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 |
28 |
--------------------------------------------------------------------------------