Testing Typescript Applications (WIP)
Testing is an essential part of the software development process. In this guide, you will learn about the various tools and techniques available for testing applications.
IntroductionWhat does it mean to test your code?What is testing?The feedback loopUnit TestingUnit Testing Tools: JestIntegration TestingEnd-to-end (E2E) TestingBenefits of E2E testsConsiderations for E2E testsCommon scenarios for end-to-end tests:Best practices for end-to-end-testsPage Objects PatternAvoiding waiting for fixed amounts of timeE2E Testing Tools: CypressInstallationNPM ScriptsConfigurationGithub actionsCommon Testing Mistakes1. Testing Implementation Details2. 100% code coverage3. Repeat TestingBest practicesThe rule of 3AAA pattern

Introduction
What does it mean to test your code?
We’re always testing our code. We pull it up in the browser and poke at it. Does it do the thing we were expecting it to do? Yes? Then the code works. It’s time to go and celebrate. No? Well then, back to the drawing board, right?
For small code bases, this works. Write or change some code and then flip over to the browser and check to see if you got the desired result. The problem is that this doesn’t scale very well. When our applications start getting big, we end up with more and more places to poke.
Even worse: we can end up in a situation where changing code in one place causes something to break somewhere else—somewhere that we’re not currently poking.
What is testing?
Testing is an essential part of the software development process. It helps to ensure that the application is functioning as expected and catches any potential issues before the application is deployed to production.
The biggest and most important reason that I write tests is CONFIDENCE. I want to be confident that the code I'm writing for the future won't break the app that I have running in production today. So whatever I do, I want to make sure that the kinds of tests I write bring me the most confidence possible and I need to be cognizant of the trade-offs I'm making when testing.
The feedback loop
Different types of tests cover different aspects of a project. Nevertheless, it is important to differentiate them and understand the role of each type. Confusing which tests do what makes for a messy, unreliable testing suit.
There are a variety of tools and approaches available for testing applications, including:
- Unit tests: focus on testing individual units or components of the application in isolation from the rest of the application. This is useful for ensuring that each component is working correctly on its own.
- Integration tests: focus on testing how different components of the application work together. This is useful for ensuring that the application functions correctly as a whole.
- End-to-end tests: focus on testing the application from the user's perspective, simulating how a user would interact with the application. This is useful for ensuring that the application functions correctly from end to end and that all necessary features are implemented.
There are many more types of tests but, for the sake of practicy, we’ll be only covering the above mentioned. Other tests may include:
- Penetration
- Performance
- Visual regression
- Accessibility
It is recommended to use a combination of these testing approaches in order to thoroughly test an application. By investing in a robust testing strategy, developers can improve the quality and reliability of their application.

Unit Testing
Unit testing is the most basic building block for testing. It is a software testing technique in which individual units or components of a software application are tested in isolation from the rest of the application.
This sort of testing is crucial for any front-end application because, with it, your components are tested against how they’re expected to behave, which leads to a much more reliable codebase and app. This is also where things like edge cases can be considered and covered.
Unit tests are particularly great for testing APIs. But rather than making calls to a live API, hardcoded (or “mocked”) data makes sure that your test runs are always consistent at all time.
Let’s take a super simple (and primitive) function as an example:
The goal of unit testing is to validate that each unit or component of the application is working correctly and as expected by offering quick and precise feedback during the process.

These tests are inexpensive and quick to write, but they cover only a small part of your application, and the guarantees they provide are weaker. Just because functions work in isolation for a few cases doesn’t mean your whole software application works, too.
A unit test shouldn’t:
🚫 Call a DB
🚫 Run a browser
🚫 Make an HTTP call
🚫 Rely on stuff on other machines
A unit test should pass without a network connection.
“ This sequence is also known as the three As pattern: arrange, act, assert.”
Unit Testing Tools: Jest
yarn add -D jest
Components that depend on State providers - overriding the default render from testing-library/react
Jest watch

Integration Testing
Integration Testing
- Level: Medium
- Scope: Tests Interactions between units.
- Possible tools: AVA, Jest, Testing Library
The idea behind integration tests is to mock as little as possible. I pretty much only mock:
- Network requests (using MSW)
- Components responsible for animation (because who wants to wait for that in your tests?)
Pending

End-to-end (E2E) Testing
End-to-end (E2E) tests are a type of automated test that validate the overall flow of an application from start to finish. E2E tests simulate the actions of a real user interacting with the application, and are often used to catch integration and functional issues that unit tests may not detect.
Because these tests are at the highest possible level of integration, when considering the testing pyramid, even within the “end-to-end” layer, they are at the very top: with a single test, you were able to cover a large part of your application reliably. Therefore, you need fewer end-to-end tests than other kinds of tests.

Benefits of E2E tests
These type of tests help ensure that your application is working correctly and as expected from the user's perspective, providing the higher level of validation from the acceptance criteria point of view.
E2E tests also help improving the overall quality and reliability of your application, as they can catch issues that may not be detectable by other types of testing (e.g: new features or changes to your application breaking existing functionality), therefore increasing the confidence of your team providing an additional level of assurance.
Last, E2E tests help reduce the time and effort required to manually test your application, as they can be run automatically as part of your continuous integration and delivery (CI/CD) pipeline.
Benefits of E2E tests
- Validation from user’s perspective
- Improve quality and reliability of your app
- Improve the confidence of your team
Considerations for E2E tests
Because these tests tend to be more time-consuming to write and to execute, it’s wise to avoid having to update them multiple times as you develop new features. Therefore, It’s recommended to write these kinds of tests after implementing complete pieces of functionality.
To decide in which circumstances you should write UI-based end-to-end tests, you must carefully consider how critical the feature under test is, how labor-intensive it is to validate it manually, and how much it will cost to automate its tests.
The more critical a feature is, and the more time you need to test it manually, the more crucial it is to write UI-based end-to-end tests.
On the other hand, if you have a small, inessential feature, such as a reset button that clears the form’s content, you don’t necessarily need to invest time in writing a UI-based end-to-end test.
Considerations for E2E tests
- More difficult to set up, run, and maintain
- Provision testing infrastructure in CI
- Testing certain scenarios require more setup
Common scenarios for end-to-end tests:
- Validating critical workflows for use cases
- Ensuring data is persisted and displayed through multiple screens
- Running Smoke Tests and System Checks before deployment
Best practices for end-to-end-tests
“In this section, I’ll teach you the best practices—the mise en place equivalents—for writing fully integrated end-to-end tests.”
Page Objects Pattern
Page objects are objects that encapsulate the interactions you make with a page. Besides centralizing selectors, because page objects couple a test’s actions to a page’s semantics instead of its structure, they make your tests more readable and maintanable.
This page object should include all the necesary methods to compose our tests related to this page, including actions like navigatig to the page, querying elements, interacting with elements, etc.
export class InventoryPageTester { static visit() { cy.visit("http://localhost:3000/blo"); } static getSubmitButton() { return cy.get('button[type="submit"]') .contains("Add to inventory"); } static addItem(itemName, quantity) { cy.get('input[placeholder="Item name"]') .clear() .type(itemName); cy.get('button[type="submit"]') .contains("Add to inventory") .click(); } static findItem(itemName, quantity) { return cy.contains("li", `${itemName} - Quantity: ${quantity}`); } }
When using the page objects pattern, instead of repeating selectors and actions throughout your tests, you will use a separate object’s methods into which those actions are encapsulated.
import { InventoryPageTester } from "../pages/inventoryPageTester"; describe("item submission", () => { it("can add items through the form", () => { InventoryManagement.visit(); InventoryManagement.addItem("cheesecake", "10"); InventoryManagement.findItemEntry("cheesecake", "10"); }); // ... });
By making tests use a page object’s methods, you avoid having to change multiple tests to update selectors. Instead, when you need to update selectors, you will have to change only a page object’s methods. By diminishing the effort necessary to keep tests up-to-date, you will make it quicker to do changes and, therefore, reduce your tests’ costs.
NOTE: Sharing page object instances is a bad idea because it can cause one test to interfere into another.
Avoiding waiting for fixed amounts of time
“As a rule of thumb, whenever using Cypress, you should avoid waiting for a fixed amount of time. In other words, using cy.wait is almost always a bad idea.
Waiting for a fixed amount of time is a bad practice because your software may take a different time to respond every time a test runs. If, for example, you’re always waiting for your server to respond to a request within two seconds, your tests will fail if it takes three. Even if you try to err on the side of caution and wait for four seconds instead, that still doesn’t guarantee your tests will pass.
Additionally, by increasing these waiting periods in an attempt to make tests deterministic, you’ll be making your tests slower. If you’re always waiting for four seconds for your server to respond but 95% of the time it responds within a second, most of the time you’ll be unnecessarily delaying your test by three seconds.”
Instead of waiting for a fixed amount of time, you should wait for conditions to be met.
E2E Testing Tools: Cypress
Installation
You can install and configure ESLint using this command:
yarn add cypress --dev
NPM Scripts
Add the following npm scripts to run Cypress whenever we want with the proper command line flags.
"scripts": { "cypress:open": "cypress open", "cypress:run": "cypress run" }
Configuration
Launching Cypress for the first time, you will be guided through a wizard that will create a Cypress configuration file for you. This file is used to store any configuration specific to Cypress.
import { loadEnvConfig } from '@next/env' import { defineConfig } from 'cypress' const { combinedEnv } = loadEnvConfig(process.cwd()) export default defineConfig({ env: combinedEnv, e2e: { baseUrl: 'http://localhost:3000', retries: { runMode: 3 }, viewportHeight: 1080, viewportWidth: 1920, video: false, screenshotOnRunFailure: false } })
In case you’re using Typescript, it’s recommended to create a
tsconfig.json
inside your cypress folder with the following configuration:{ "extends": "../tsconfig.json", "compilerOptions": { "target": "es5", "lib": ["es5", "dom"], "types": ["cypress", "node"] }, "include": ["**/*.ts"] }
Github actions
GitHub offers developers Actions that provide a way to automate, customize, and execute your software development workflows within your GitHub repository. Detailed documentation is available in the GitHub Action Documentation.
name: E2E Tests on: [push] jobs: e2e-tests: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@master - name: Install dependencies run: npm install - name: Cypress run uses: cypress-io/github-action@master env: // Remember to add any env variables // access to gh action secrets via "secrets.***" NOTION_EXTENSION_TOKEN: ${{ secrets.MY_ENV_VAR }} with: build: npm run build start: npm run start
TEST COVERAGEE
Common Testing Mistakes
1. Testing Implementation Details
I harp on this a lot (read more). It's because it's a huge problem in testing and leads to tests that don't give nearly as much confidence as they could. Here's a very simple example of a test that's testing implementation details:
So why is this testing implementation details? Why is it so bad to test implementation details? Here are two truths about tests that focus on implementation details like the test above:
- I can break the code and not the test (eg: I could make a typo in my button's onClick assignment)
- I can refactor the code and break the test (eg: I could rename increment to updateCount)
These kinds of tests are the worst to maintain because you're constantly updating them (due to point #2), and they don't even give you solid confidence (due to point #1).
2. 100% code coverage
Trying to go for 100% code coverage for an application is a total mistake and I see this all the time. Interestingly I've normally seen this as a mandate from management, but wherever it's coming from it's coming out of a misunderstanding of what a code coverage report can and cannot tell you about the confidence you can have in your codebase.
What code coverage is telling you:
- This code was run when your tests were run.
What code coverage is NOT telling you:
- This code will work according to the business requirements.
- This code works with all the other code in the application.
- The application cannot get into a bad state
Another problem with code coverage reports is that every line of covered code adds just as much to the overall coverage report as any other line. What this means is that you can increase your code coverage just as much by adding tests to your "About us" page as you can by adding tests to your "Checkout" page. One of those things is more important than the other, and code coverage can't give you any insight into that for your codebase...
There's no one-size-fits-all solution for a good code coverage number to shoot for. Every application's needs are different. I concern myself less with the code coverage number and more with how confident I am that the important parts of my application are covered. I use the code coverage report to help me after I've already identified which parts of my application code are critical. It helps me to know if I'm missing some edge cases the code is covering but my tests are not.
I should note that for open source modules, going for 100% code coverage is totally appropriate because they're generally a lot easier to keep at 100% (because they're smaller and more isolated) and they're really important code due to the fact that they're shared in multiple projects.
I talked a bit about this in my livestream the other day, check it out!
3. Repeat Testing
One of the biggest complaints people have about end-to-end (E2E) tests is how slow and brittle they are when compared to integration or unit tests. There's no way you'll ever get a single E2E test as fast or reliable as a single unit test. It's just never going to happen. That said a single E2E test will get you WAY more confidence than a single unit test. In fact, there are some corners of confidence that are impossible to get out of unit tests that E2E tests are great at, so it's definitely worth having them around!
But this doesn't mean that we can't make our E2E tests faster and more reliable than you've likely experienced in the past. Repeat testing is a common mistake that people make when writing E2E tests that contribute to the poor performance and reliability.
Tests should always work in isolation. So that means every test should be executed as a different user. So every test will need to register and login as a brand new user right? Right. So you need to have a few page objects for the registration and login pages because you'll be running through those pages in every test right? WRONG! That's the mistake!
Let's take a step back. Why are you writing tests? So you can ship your application with confidence that things wont break! Let's say you have 100 tests that need an authenticated user. How many times do you need to run through the "happy path" registration flow to be confident that flow works? 100 times or 1 time? I think it's safe to say that if it worked once, it should work every time. So those 99 extra runs don't give you any extra confidence. That's wasted effort.
So what do you do instead? I mean, we already established that your tests should work in isolation so you shouldn't be sharing a user between them. Here's what you do: make the same HTTP calls in your tests that your application makes when you register and log in a new user! Those requests will be MUCH faster than clicking and typing around the page and there's less of a chance for false negative failures. And as long as you keep one test around that actually does test the registration/login flow you haven't lost any confidence that this flow works.
Best practices
The rule of 3
This rule, introduced by Roy Osherove, will help you clarify what a test is supposed to accomplish. It’s is a well-known practice in unit testing, but it would be helpful in end-to-end testing as well. According to the rule, a test’s title should consist of three parts:
- What is being tested?
- Under what circumstances would it be tested?
- What is the expected result?
//1. unit under test describe('Products Service', function() { describe('Add new product', function() { //2. scenario and 3. expectation it('When no price is specified, then the product status is pending approval', ()=> { const newProduct = new ProductService().add(...); expect(newProduct.status).to.equal('pendingApproval'); }); }); });
AAA pattern
There is one pattern that might come in handy, the AAA pattern. AAA is short for “arrange, act, assert”, which tells you what to do in order to structure a test clearly. Divide the test into three significant parts. Being suitable for relatively short tests, this pattern is mostly encountered in unit testing. In short, these are the three parts:
- Arrange: Here, you would set up the system being tested to reach the scenario that the test aims to simulate. This could involve anything from setting up variables to working with mocks and stubs.
- Act: In this part, you would run the unit under the test. So, you would do all of the steps and whatever needs to be done in order to get to the test’s result state.
- Assert: This part is relatively self-explanatory. You would simply make your assertions and checks in this last part.
This is another way of designing a test in a lean, comprehensible way. With this rule in mind, we could change our poorly written test to the following:
describe('Context menu', () => { it('should open the context menu on click', () => { // Arrange const contextButtonSelector = 'sw-context-button'; const contextMenuSelector = '.sw-context-menu'; // Assert state before test let contextMenu = wrapper.find(contextMenuSelector); expect(contextMenu.isVisible()).toBe(false); // Act const contextButton = wrapper.find(contextButtonSelector); await contextButton.trigger('click'); // Assert contextMenu = wrapper.find(contextMenuSelector); expect(contextMenu.isVisible()).toBe(true); }); });
The bolded keywords in the last bullet point hint at another pattern from behavioral-driven development (BDD). It’s the given-when-then pattern, developed by Daniel Terhorst-North and Chris Matts. You might be familiar with this one if you’ve written tests in the Gherkin language:
// Jest describe('Context menu', () => { it('should open the context menu on click', () => { // Given const contextButtonSelector = 'sw-context-button'; const contextMenuSelector = '.sw-context-menu'; // When let contextMenu = wrapper.find(contextMenuSelector); expect(contextMenu.isVisible()).toBe(false); const contextButton = wrapper.find(contextButtonSelector); await contextButton.trigger('click'); // Then contextMenu = wrapper.find(contextMenuSelector); expect(contextMenu.isVisible()).toBe(true); }); });