Welcome to the "Automated Testing Workshop Hands-On". This workshop is designed to guide you through the process of setting up and using several important testing tools. We will cover static testing, unit testing, integration testing, and end-to-end testing.
Let's get started!
Initial Setup1. Static Analysis Testing2. Unit Testing3. Integration Testing4. End to End Testing with a Sample React Application
Initial Setup
- Go to and fork the repo by clicking the Fork button.
NOTE: Make sure to uncheck
Copy the
main
branch only
.
NOTE 2: Verify after forking that you have all the branches we need for this workshop…- Clone your repo with command:
git clone <REPO_URL>
1. Static Analysis Testing
Let's break down the static testing process step by step, using ESLint, Prettier, TypeScript, lint-staged, and Husky to improve a piece of JavaScript code.
Consider the following JavaScript object:
var participant = {firstName:'John',lastName:"Doe",age:25, attending:true}
This code has several issues:
- The variable is declared with
var
, which is not recommended because it's function-scoped, not block-scoped. This can lead to unexpected bugs.
- The object properties lack spaces after the colons, which is against common style guides in JavaScript.
- The variable name
participant
doesn't convey any type information, which TypeScript could provide.
Let's see how our tools can help address these issues.
ESLint
ESLint is a tool that enforces coding standards using a set of customizable rules. It can help catch style issues and minor bugs, ensuring our code is clean and consistent.
Setting up ESLint and running it on the file would flag the
var
declaration and the missing spaces in our object properties.- Install ESLint:
npm install eslint --save-dev
- Initialize ESLint:
npx eslint --init
Follow the instructions below and answer accordingly.
How would you like to use ESLint?
> To check syntax and find problems
What type of modules does your project use?
> None of these
Which framework does your project use?
> None of these
Which framework does your project use?
> No
Which framework does your project use?
> ✔︎ Browser
> ✔︎ Node
Would you like to install them now?
> Yes
Which package manager do you want to use?
> npm
- Run ESLint:
npx eslint index.js
1:5 error 'participant' is assigned a value but never used no-unused-vars
✖ 1 problem (1 error, 0 warnings)
- Let’s talk about configuring rules.
In ESLint, rules control what errors or warnings to display. Each rule can have its own severity level (
0
= off, 1
= warn, 2
= error) and, optionally, some rule-specific options. For example, the rule
"semi": [2, "always"]
enforces the use of semicolons, and will cause an error (severity 2
) if a semicolon is missing.Here's how to add this rule to your ESLint configuration file (
.eslintrc.config.mjs
):{ "rules": { semi: [2, "always"], quotes: [2, "double"], } }
To apply the above rule, update
eslint.config.mjs
and add the rules
as per above to array of config.export default [ {files: ["**/*.js"], languageOptions: {sourceType: "script"}}, {languageOptions: { globals: {...globals.browser, ...globals.node} }}, pluginJs.configs.recommended, // add it here below 👇 { "rules": { semi: [2, "always"], quotes: [2, "double"], } } ];
Run the command again:
npx eslint index.js
1:5 error 'participant' is assigned a value but never used no-unused-vars
1:30 error Strings must use doublequote quotes
1:75 error Missing semicolon semi
✖ 3 problems (3 errors, 0 warnings)
1 error and 0 warnings potentially fixable with the --fix option.
- We can use ESLint to fix common issues when it can by passing
--fix
command.
npx eslint index.js --fix
from:
var participant = {firstName:'John',lastName:"Doe",age:25, attending:true}
to
var participant = {firstName:"John",lastName:"Doe",age:25, attending:true};
And look at that, it might just be trivial that it automatically added a semicolon (;) and made use of double quotes (”) but you useful this can be when you want to update a large codebase in making things consistent and more importantly, it’ll help you catch some more errors.
Expand to learn more on this with React as an example
ESLint comes with a large number of built-in rules, and you can also add rules provided by plugins. For instance, if your project is using React, you might use the
eslint-plugin-react
to enforce React-specific best practices.Here's an example of adding React-specific rules:
{ "plugins": ["react"], "rules": { "react/jsx-uses-react": "error", "react/jsx-uses-vars": "error", } }
The rule
"react/jsx-uses-react"
prevents React from being incorrectly marked as unused, and "react/jsx-uses-vars"
prevents variables used in JSX from being incorrectly marked as unused.Remember that ESLint rules should be configured to match your coding style and the specific requirements of your project.
Prettier
Prettier is an opinionated code formatter that enforces a consistent style by parsing your code and reprinting it with its own rules.
After running Prettier, it would automatically fix the formatting issue in our object, such as the missing spaces after the colons.
- Install Prettier:
npm install --save-dev prettier
- Run Prettier:
npx prettier --write index.js
from:
var participant = {firstName:"John",lastName:"Doe",age:25, attending:true};
to:
var participant = { firstName: "John", lastName: "Doe", age: 25, attending: true, };
TypeScript (optional)
TypeScript is a typed superset of JavaScript that adds static types to the language.
This is completely optional as TypeScript (aka TS) has it’s own learning curve. Working with types might hinder you when you spend time resolving types instead of focusing on working on the logic.
- First, let’s rename
index.js
toindex.ts
to change this file as a TS file which allows us to use of TypeScript features.
- Second, let's define the
Participant
interface that contains the properties of a participant object.
interface Participant { firstName: string; lastName: string; age: number; attending: boolean; }
- Then, we declare our
participant
variable and specify its type asParticipant
.
const participant: Participant = { firstName: 'John', lastName: 'Doe', age: 25, attending: true, };
Now, TypeScript will alert us if we try to assign a value to
participant
that doesn't conform to the Participant
interface.For example, when we add
favoriteColor
, it shows a squiggly red underline that denotes that this property might not be incorrectly added as this property is not in Participant
interface we define above.To resolve this, we’ll have to add the said property in our interface above.
interface Participant { firstName: string; lastName: string; favoriteColor: string; // <-- added age: number; attending: boolean; }
One also added benefit that TS allows us is it gives us, for example when we’re using IDE like VSCode, it gives you auto suggestions that are properly typed.
NOTE: If you choose to install TypeScript, re-run the configuration with ESLint above with
npx eslint --init
again.
If you encounter error installing, delete the node_modules
directory and package-lock.json
and eslint.config.mjs
file.Automate linting and formatting with lint-staged & husky
Now, let's automate the process with lint-staged and husky.
Lint-staged runs linters on staged files, and Husky can prevent bad commits or pushes by enabling Git hooks.
- Install Husky and lint-staged:
npm install husky lint-staged --save-dev
- Run
npx husky init
This creates a
.husky
in our root directory- Now let’s update
pre-commit
and make use oflint-staged
, so let’s replacenpm test
withnpx lint-staged
- Let’s configure
lint-staged
to do run ESLint and Prettier in our.js
and/or.ts
files by creating a.lintstagedrc.json
file with contents:
{ "*.{js,ts}": ["eslint --fix", "prettier --write --ignore-unknown"] }
- Then we can commit and it’ll automatically run our linter and code formatter.
To resolve the issue above, let’s just log the
participant
console.log(participant);
With this setup, every time you commit your code, Husky will trigger the
pre-commit
hook, which runs lint-staged. Lint-staged then runs ESLint and Prettier on your staged JavaScript and/or TypeScript files, automatically fixing any issues. This is how you ensure everyone on your team is following proper style guide and makes use of best practices.2. Unit Testing
Our next focus will be on the concept of unit testing. Unit testing is a method of software testing that verifies the correctness of individual, isolated parts of a program.
The primary objective of unit testing is to isolate each part of the program and show that these individual parts are correct in terms of their behavior.
In this workshop, we will be utilizing
First, let’s create a codespace…
Now, let’s check out code snippet below…
Let's consider the following JavaScript function:
// person.js function getFullName(participant) { if (!participant?.firstName && !participant?.lastName) { return "First name and last name is required!" } if (!participant.lastName) { return "Last name is required!" } if (!participant.firstName) { return "First name is required!" } return participant.firstName + " " + participant.lastName; } module.exports = { getFullName };
This function is designed to concatenate the first name and the last name of a workshop participant.
However, what if the participant object does not possess the
firstName
and/or lastName
properties which are both required in this case?Let's walk through the process of setting of writing a test that verifies that
getFullName
handles the missing properties well.- Install Jest by running the following command in your terminal:
npm install --save-dev jest
- Next, create a test file named
person.test.js
. This file will contain our unit tests.
- To run Jest and thus, your unit tests, use the command:
npx jest
With Jest set up, we can now write a test case that passes an object without
firstName
and lastName
properties. This allows us to ensure that our
getFullName
function is capable of handling our requirement.Let’s write our first unit test…
SHOW CODE
test('getFullName without first name and last name is handled properly', () => { const participant = {}; const expected = "First name and last name is required!"; const result = getFullName(participant); expect(result).toBe(expected); });
In this test, we create a participant object without the
firstName
and lastName
properties. We then define our expected output as
First name and last name is required!
, since our function currently does not handle missing properties. We then call our function with the participant object and expect the result to be our expected output. If the function does not return as per our expected, the test will fail, indicating that our function does not handle missing properties as expected.
This is how you can use Jest to write comprehensive unit tests, ensuring that your functions behave as expected even in edge cases. This not only makes your code more robust, but also makes future changes and additions to the codebase easier and safer.
You can read more about Jest and how to get started on its official site.
💪 CHALLENGE
- Write a test that will verify that first name is required
- Our requirement has changed, we now need to also need to pass our
middleName
. Our updatedperson.js
below:
function getFullName(participant) { if (!participant?.firstName && !participant?.middleName || !participant?.lastName) { return "First name, middle name, and last name is required!" } if (!participant.lastName) { return "Last name is required!" } if (!participant.firstName) { return "First name is required!" } return participant.firstName + " " + participant.lastName; } module.exports = { getFullName };
- Write a test that will verify that if
middleName
is missing, it returnsMiddle name is required!
NOTE: We have left something intentionally for you to figure out…
3. Integration Testing
Integration testing is a phase in software testing where individual software modules are combined and tested as a group.
We will use the DOM Testing Library and React Testing Library for this part.
First, let’s create a codespace…
Let's start with a plain JavaScript example:
function greeting(person) { return 'Hello, ' + person.name; } function introduce(person) { return greeting(person) + '. I am ' + person.age + ' years old.'; } module.exports = { introduce, greeting }
In the code above, two functions
greeting
and introduce
are interdependent. If greeting
fails, introduce
will also fail. Here are potential issues:- If
person
isnull
orundefined
, the code will throw aTypeError
.
- If
person
does not have aname
orage
property, the output will include 'undefined'.
- If
person.name
orperson.age
is an empty string, the output will be incorrect.
We can use the Jest to write tests that catch these issues. Once again, let’s add
jest
to our project:npm install --save-dev jest
And we can write tests to handle different scenarios such as it how it handles missing person, missing properties and empty properties.
Create a
integration.test.js
// integration.test.js test('introduce handles missing person', () => { const result = introduce(); expect(result).toBe('Hello, undefined. I am undefined years old.'); });
💪 CHALLENGE
- Create a test that will make sure
introduce
handles missing properties. Person is{}
in this case as input.
- Create a similar test that handles empty string property values. Person here is
{ name: '', age: '' }
No peeking 🫣
SHOW SOLUTION
test('introduce handles missing properties', () => { const person = {}; const result = introduce(person); expect(result).toBe('Hello, undefined. I am undefined years old.'); }); test('introduce handles empty string property values', () => { const person = {name: '', age: ''}; const result = introduce(person); expect(result).toBe('Hello, . I am years old.'); });
Now, let's consider a React example:
Let’s skip the setup part but if you’re curious how, expand this section.
- To add support for JSX, add babel presets and add
.babelrc
to your project
npm install --save-dev @babel/preset-env @babel/preset-react
Create
.babelrc
file in the root directory{ "presets": [ "@babel/preset-env", ["@babel/preset-react", { "runtime": "automatic" }] ] }
- Add react testing library dependencies
npm install --save-dev @testing-library/react @testing-library/jest-dom
- To support SVG and CSS files add jest-svg-transformer and identity-obj-proxy. Then add into moduleMapper inside package.json jest config.
In your package json, add the following line below.
"jest": { "moduleNameMapper": { "^.+\\.svg$": "jest-svg-transformer", "^.+\\.(css|less|scss)$": "identity-obj-proxy" } }
- To support web environment API, install
jest-environment-jsdom
add into jest config and add in our previousjest
config property inpackage.json
earlier.
npm install --save-dev jest-environment-jsdom
"jest": { "testEnvironment": "jsdom", "moduleNameMapper": { "^.+\\.svg$": "jest-svg-transformer", "^.+\\.(css|less|scss)$": "identity-obj-proxy" } }
- Additionally add
@testing-library/jest-dom
package and configuresetupTests.js
in our root directory.
npm install --save-dev @testing-library/jest-dom
// setupTests.js import "@testing-library/jest-dom";
"jest": { "testEnvironment": "jsdom", "moduleNameMapper": { "^.+\\.svg$": "jest-svg-transformer", "^.+\\.(css|less|scss)$": "identity-obj-proxy" }, "setupFilesAfterEnv": [ "<rootDir>/setupTests.js" ] }
Let’s focus on our code, instead. 😉
// App.jsx import React from "react"; function Greeting({ person }) { return <p>Hello, {person.name}</p>; } function Introduction({ person }) { return ( <div> <Greeting person={person} /> <p>I am {person.age} years old.</p> </div> ); } const person = { name: "John", age: 25, }; export default function App() { return <Introduction person={person} />; }
This code has similar issues as the plain JavaScript example.
We can use the React Testing Library to write tests that catch these issues:
- First, install the React Testing Library by running the following command in your terminal:
npm install --save-dev jest @testing-library/react
. Since we’ve already set it up properly, we don’t need to do this.
Proceed to next step below
- Then, create a new file named
App.test.js
inside thesrc
directory. This file will contain our integration tests.
Here are some tests you might write for the
App
component:import { render, screen } from "@testing-library/react"; import App from './App' test('Introduction handles missing person', () => { // ARRANGE render(<App />); // ACT // ASSERT expect(screen.getByText('Hello, John')).toBeInTheDocument(); expect(screen.getByText('I am 25 years old.')).toBeInTheDocument(); });
In these tests, we use the
render
function from React Testing Library to render our App
component, and the based from screen
(which you can think whatever RTL sees as a whole) function to find elements by their display text. We then use the toBeInTheDocument
function from @testing-library/jest-dom
to ensure the elements are in the document.These tests ensure that the
App
component and its child components (Introduction
and Greeting
) handle missing or empty properties correctly.💪 CHALLENGE
- Write a test that will verify that
<Introduction />
component can handlenull
value.
person={null}
- Write a test that will verify that
<Introduction />
component can handle missing properties
person={}
- Same above but handle empty properties.
const person = { name: '', age: '' }
- Can you combine all of these in one test?
Ooops, no peeking! 🫣
SHOW SOLUTION
test('Introduction handles null person', () => { const { getByText } = render(<Introduction person={null} />); expect(getByText('Hello, ')).toBeInTheDocument(); expect(getByText('I am years old.')).toBeInTheDocument(); }); test('Introduction handles missing properties', () => { const person = {}; const { getByText } = render(<Introduction person={person} />); expect(getByText('Hello, ')).toBeInTheDocument(); expect(getByText('I am years old.')).toBeInTheDocument(); }); test('Introduction handles empty properties', () => { const person = {name: '', age: ''}; const { getByText } = render(<Introduction person={person} />); expect(getByText('Hello, ')).toBeInTheDocument(); expect(getByText('I am years old.')).toBeInTheDocument(); });
If you’re curious about how about if we’re not using a Framework like React?
Here's how you can setup DOM Testing Library and write tests for it:
First, let's install the DOM Testing Library and its peer dependencies.
- Install Jest: Jest is a JavaScript testing framework that we will use to run our tests.
npm install --save-dev jest
- Install @testing-library/jest-dom: This library provides custom Jest matchers that you can use to extend Jest's default matchers.
npm install --save-dev @testing-library/jest-dom
- Install @testing-library/dom: This is the DOM Testing Library that provides utilities for testing DOM nodes.
npm install --save-dev @testing-library/dom
Now that you've installed the necessary libraries, let's write some tests.
Create a new file named
script.test.js
in your project directory and add the following code:<!DOCTYPE html> <html> <body> <p id="greeting"></p> <p id="introduction"></p> <script> var person = { name: "John", age: 25, }; document.getElementById("greeting").innerHTML = "Hello, " + person.name; document.getElementById("introduction").innerHTML = "I am " + person.age + " years old."; </script> </body> </html>
To start setting up DOM testing library and writing tests for the provided code snippet, please follow the steps below:
- First, install the DOM testing library. Use npm or yarn to add it to your project:
npm install --save-dev @testing-library/dom # or yarn add --dev @testing-library/dom
- Once installed, you can now start writing tests. Create a new test file (e.g.,
app.test.js
). In this file, you'll import the necessary functions from the DOM Testing Library, and write your tests.
import { getByText, getByTestId } from '@testing-library/dom' import '@testing-library/jest-dom/extend-expect' test('displays greeting and introduction correctly', () => { // Create a new HTML document and set its body to the provided HTML snippet document.body.innerHTML = ` <html> <body> <p id="greeting"></p> <p id="introduction"></p> </body> </html> `; // Simulate the script from the provided HTML snippet var person = { name: "John", age: 25, }; document.getElementById("greeting").innerHTML = "Hello, " + person.name; document.getElementById("introduction").innerHTML = "I am " + person.age + " years old."; // Now, we can start our assertions expect(getByText(document.body, 'Hello, John')).toBeInTheDocument(); expect(getByText(document.body, 'I am 25 years old.')).toBeInTheDocument(); });
In the test, we first create a new HTML document and set its body to the provided HTML snippet. We then simulate the script that runs on this HTML, which sets the
innerHTML
of the elements with IDs of "greeting" and "introduction". Finally, we use the getByText
function from the DOM Testing Library to find the elements by their text content, and assert that they're in the document.Finally, you can run your tests using Jest:
npx jest
This will run your tests and provide output in your terminal.
4. End to End Testing with a Sample React Application
Finally, we will cover end-to-end testing with a practical example by using a sample React application.
In your cloned repo, in root directory, run command:
git checkout 03-end-to-end-testing
NOTE: Since it’s going to take a while to install things out, let’s first start with initializing Playwright and explain things later.
Run command in your terminal:
npm init playwright@latest
Getting started with writing end-to-end tests with Playwright:
Initializing project in '.'
✔ Do you want to use TypeScript or JavaScript? · JavaScript
✔ Where to put your end-to-end tests? · tests
✔ Add a GitHub Actions workflow? (y/N) · false
✔ Install Playwright browsers (can be done manually via 'npx playwright install')? (Y/n) · true
✔ Install Playwright operating system dependencies (requires sudo / root - can be done manually via 'sudo npx playwright install-deps')? (y/N) · trueNow let’s focus our attention to our code while waiting…
Our example React app has two basic functionalities:
- Login with credentials -
user
&pass
- Post a message one time. If you post again, it’ll replace the previous message.
import React, { useState } from 'react'; function App() { const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [isLoggedIn, setIsLoggedIn] = useState(false); const [message, setMessage] = useState(''); const [post, setPost] = useState(''); const handleLogin = () => { if(username === 'user' && password === 'pass') { setIsLoggedIn(true); } } const handlePost = () => { setPost(`${username}: ${message}`); setMessage(''); } return ( <div> {!isLoggedIn ? ( <div> <input type="text" onChange={(e) => setUsername(e.target.value)} placeholder="Username" /> <input type="password" onChange={(e) => setPassword(e.target.value)} placeholder="Password" /> <button onClick={handleLogin}>Login</button> </div> ) : ( <div> <input type="text" value={message} onChange={(e) => setMessage(e.target.value)} placeholder="Write a message" /> <button onClick={handlePost}>Post</button> <p>{post}</p> </div> )} </div> ); } export default App;
With this setup, our application allows a user to "login" with static credentials (username: 'user', password: 'pass'), write a message and post it.
End-to-end testing is testing the real application runtime like how normally users interact with the app, meaning, clicking a button, filling out an some text input fields, etc.
Let’s run our application
npm run dev
And open http://localhost:5173 in our browser.
Play around it to get familiar with the functionality and verify they work according to what we described earlier.
You’ve just done manual testing! Naks. 😉
Now, let's write end-to-end tests using Playwright.
Playwright is a Node.js library to automate Chromium, Firefox and WebKit browsers with a single API.
It is a perfect tool to automate these kinds of processes.
What we particularly like about Playwright is it has a code generator that allows us to record our steps.
Run command in a new terminal:
npx playwright codegen http://localhost:5173/
After performing the same steps we did, we now this code snippet below we can use to create a test file.
Now, let’s create a
app.spec.js
in tests
folder and paste the code we generated earlier.Example below:
import { test, expect } from '@playwright/test'; test('test', async ({ page }) => { await page.goto('http://localhost:5173/'); await page.getByPlaceholder('Username').click(); await page.getByPlaceholder('Username').fill('user'); await page.getByPlaceholder('Password').click(); await page.getByPlaceholder('Password').fill('pass'); await page.getByRole('button', { name: 'Login' }).click(); await page.getByPlaceholder('Write a message').click(); await page.getByPlaceholder('Write a message').fill('ok rta?'); await page.getByRole('button', { name: 'Post' }).click(); await expect(page.getByText('user: ok rta?')).toBeVisible(); await page.getByPlaceholder('Write a message').click(); await page.getByPlaceholder('Write a message').fill('oo gd'); await page.getByRole('button', { name: 'Post' }).click(); await expect(page.getByRole('paragraph')).toContainText('user: oo gd'); });
To run our test, we can use the following commands:
npx playwright test
- runs our tests headless (without the browser showing up)npx playwright test --ui
- starts the interactive UI mode.Finally, you can run
npx playwright show-report
after each tests which will show you a summary of the test you just performed.This is how you can use Playwright to write end-to-end tests, ensuring that your application behaves as expected. This not only makes your code more robust, but also makes future changes and additions to the codebase easier and safer.
To learn more, we suggest you do further reading at Playwright especially the Best Practices guide. Also, understand the concepts about Locators and why they’re preferred.
💪 CHALLENGE
- Write tests for https://todomvc.com/examples/react/dist/ to verify the following:
- It creates a todo
- Todo can be completed
- Todo can be removed
- Todo can be filtered
- Todo’s that are completed can be removed
- Todos can be marked all as completed