Unit Testing, and Testing in general, always seem to fall to the wayside with every project I create. With the idea that testing is a nice-to-have feature that can be added at the end instead of testing while developing, it’s no wonder why no testing happens with projects coming in faster than dollar hotdog night at the baseball stadium. Tests are considered nice to have because they initially slow development down, and who wants to slow down when the urgency to move to the next project is breathing down your neck? Well, the idea here is where do you want to spend your time? If we were to break down time onto a linear graph, we would typically see something along the lines of this.
If we incorporate testing into our development, then we can turn it around.
Notice that the length of time for development does increase, BUT our “hunting down and fixing bugs” are not as drastic as before. In fact, even though the development with testing increases project time initially, the overall time is shortened. We don’t have to worry about having to hunt and refactor code due to a lack of testing the code. Testing while developing also brings satisfaction for QA Testers when the project gets launched into a “demo/alpha” state by eliminating most bugs upfront.
So, now that we have covered the benefits of why we are testing and when to implement testing, we move to what kind of testing we implement. There are several kinds of testing techniques that can be implemented, but I would like to focus on three types for this blog: Unit Testing, Integration Testing, and End-To-End testing.
Unit Testing – A software development process in which the smallest testable parts of an application, called units, are individually scrutinized for proper operation.
Integration Testing – the phase in software testing in which the whole software module is tested, or if it consists of multiple software modules, they are combined and then tested as a group.
End To End Testing – a testing method that evaluates the entire application flow from start to finish.
The pyramid below illustrates how much testing to apply per testing type.
At the bottom of the pyramid, we have “Unit Test,” which can be very granular. In my opinion, we should keep Unit Tests to “pure functions.”
A quick aside: What is a pure function?
According to “GeeksForGeeks.org,” a “Pure Function” is a function (a block of code) that always returns the same result if the same arguments are passed. It does not depend on any state or data change during a program’s execution. Instead, it only depends on its input arguments.
Let us take a look at an overly simplified example in JavaScript.
function sum(numberOne: number, numberTwo: number) {
return numberOne + numberTwo;
}
This function is a pure function by definition. Therefore, it is an ideal candidate for a Unit Test.
Next in the pyramid, we have Integration Tests. Integration Tests are a way of grouping functionality together to ensure it works with various scenarios. This type of grouping can be done in different ways. For example, we could have many Unit Tests that we want to group to ensure they work together. With that said, one could argue that testing what has already been tested with Unit Tests is redundant, which they may have a point. Another type of grouping is to check, for example, how a component in React works with various components integrated into one component. Whichever path you take to group the functionality could be considered Integration Testing, and the creation of these tests will not be as many as Unit Tests.
Lastly, at the top of the pyramid, we have End-to-end testing. End-to-end testing can be created to navigate your application as a user would. Typically, this means pushing different buttons on different application pages to see if the desired output occurs. At this point, we don’t care how the code was implemented; we are just focusing on whether pushing different buttons will create a desired behavior that one would expect. Example of pushing the button “2”, then the button “+”, then the button “3”, and finally the button “=” produces “5” on the output.
How do we go about writing tests?
What is the best way to incorporate testing into our projects? The best way to test is an opinionated statement because it depends on the individual or team working on a project and their preferences.
We will implement testing throughout this blog by utilizing something called Red, Green, and Refactor. We start with a failing test, then code for the test to pass, refactor if needed, and finally begin the process again.
Organizing the test can be broken into three phases: Arrange, Act, Assert OR Given, When, and Then.
- Arrange – Set up the testing objects and prepare the prerequisites for your test.
- Act – perform the actual work of the test
- Assert – verify the result
OR
- Given – Define the state of the application up to this point. Usually involves setting up mocks or stubbing methods.
- When – Execute an action using a method or function call.
- Then – verify (assert) that the method or function call responded according to the specification.
So, let’s jump into testing a simple calculator. I started a shell of an application by using ‘npm init’ and have already wired up webpack as the bundler to enable hot reloading of our application. I also have configured TypeScript, Jest, and Cypress to get the boilerplate out of the way. The application currently consists of two files working together: index.html and index.ts. The main focus area is the ” Calculator ” directory and building and running tests within it. You can find and download the shell “here”.
Let us briefly look at the application by starting at the index.ts file; first, we grab all the elements we need to interact with from the DOM by using the DOM API. Next, we wire up all the event listens for all the buttons by first concatenating the continuation of numbers into a variable, listening to operators’ addition, subtraction, multiplication, and division, and resetting the main variable holder to zero, listening to when “clear” is pressed and resetting the variables, and finally listening to the “equals” button.
Open a terminal and run the command: npm run test
Unit Test
Let’s create our first Unit Test by adding the following into the file “calculator.test.ts” file:
import { expect } from "@jest/globals";
test("should sum two numbers", () => {
// Arrange
const numOne = 2;
const numTwo = 3;
// Act
const result = sum(numOne, numTwo);
// Assert
expect(result).toBe(5);
});
Once we add our first test, we should get an error on the bottom of “sum” because we haven’t created it yet, and this will be our “Red-Fail”.
To get this test to pass, we must implement the code in our “calculator.ts” file. This is also known as our Green State or Passing State.
export function sum(numOne: number, numTwo: number) {
return numOne + numTwo;
}
Then we import the file into the “calculator.test.ts” file:
import { sum } from "./calculator";
WOOHOO! First test passing. There is nothing to refactor at this point, so move back through the circle to “Red State/Failing State” with our next test subtraction.
test("should subtract two numbers", () => {
const result = subtract(10, 8);
expect(result).toBe(2);
});
Notice how the test fails and tells us that the “subtract” can not be found, so let’s add it.
export function subtract(numOne: number, numTwo: number) {
return numOne - numTwo;
}
Then import the file into “calculator.test.ts” :
import { sum, subtract } from "./calculator";
We can continue adding tests to subtraction if we want, such as checking to see if the result can be a negative number.
test("should subtract two numbers and return a negative number", () => {
const result = subtract(10, 15);
expect(result).toBe(-5);
});
As you can see, the tests are all still passing. YAY!
Let’s continue by adding another multiplication test.
test("should multiply two numbers", () => {
const result = multiply(10, 10);
expect(result).toBe(100);
});
Once again, we are failing, which is good.
We just need to add the code to the “calculator.ts” file.
export function multiply(numOne: number, numTwo: number) {
return numOne * numTwo;
}
And continue to our import:
import { sum, subtract, multiply } from "./calculator";
Last but not least is division, which is a little unique. We start the same by adding a test to simply divide two numbers.
test("should divid numbers", () => {
const result = divide(16, 2);
expect(result).toBe(8);
});
Of course, we fail till we add the function into “calculator.ts”.
export function divide(numOne: number, numTwo: number) {
return numOne / numTwo;
}
And again import into “calculator.test.ts”:
import { sum, subtract, multiply, divide } from "./calculator";
But what about dividing by zero?
test("should return a message if you try to divide by 0", () => {
const result = divide(16, 0);
expect(result).toBe("Can not divide by 0");
});
It looks like trying to divide by zero resulted in “Infinity,” so we finally hit a “Refactor Status” point and needed to adjust our original code for dividing in the “calculator.ts file”.
export function divide(numOne: number, numTwo: number) {
if (numTwo === 0) return "Can not divide by 0";
return numOne / numTwo;
}
After adding this simple line
if (numTwo === 0) return "Can not divide by 0";
We can see our test pick up and pass.
Integration Testing
Let’s talk about how we could have approached this from an integration standpoint by creating a function that handles calculations. We can group these tests into the jest “Describe” feature.
describe("my calculator", () => {});
We first start by trying to be able to add two numbers:
describe("my calculator", () => {
test("should sum two numbers when a '+' sign is given", () => {
// Arrange
const operator = "+";
const numOne = 5;
const numTwo = 5;
// Arrange
const result = calculate(numOne, numTwo, operator);
// Assert
expect(result).toBe(10);
});
});
We, of course, fail because we need to add calculate.
Adding a calculate function:
export default function calculate(
numOne: number,
numTwo: number,
operator: string
) {
switch (operator) {
case "+":
return sum(numOne, numTwo);
default:
return "Invalid operator";
}
}
Then add the import into the “calculator.test.ts”:
import calculate, { sum, subtract, multiply, divide } from "./calculator";
From here, we can perform tests for different operations on our calculate function to have a function that takes input and produces an output. Let’s fall into our failing test first, though.
describe("my calculator", () => {
test("should sum two numbers when a '+' sign is given", () => {
// Arrange
const operator = "+";
const numOne = 5;
const numTwo = 5;
// Arrange
const result = calculate(numOne, numTwo, operator);
// Assert
expect(result).toBe(10);
});
test("should subtract two numbers when a '-' sign is given", () => {
// Arrange
const operator = "-";
const numOne = 10;
const numTwo = 8;
// Arrange
const result = calculate(numOne, numTwo, operator);
// Assert
expect(result).toBe(2);
});
test("should multiply two numbers when a '*' sign is given", () => {
// Arrange
const operator = "*";
const numOne = 2;
const numTwo = 8;
// Arrange
const result = calculate(numOne, numTwo, operator);
// Assert
expect(result).toBe(16);
});
test("should divide two numbers when a '/' sign is given", () => {
// Arrange
const operator = "/";
const numOne = 16;
const numTwo = 2;
// Arrange
const result = calculate(numOne, numTwo, operator);
// Assert
expect(result).toBe(8);
});
test("should not be able to divide by 0", () => {
// Arrange
const operator = "/";
const numOne = 16;
const numTwo = 0;
// Arrange
const result = calculate(numOne, numTwo, operator);
// Assert
expect(result).toBe("Can not divide by 0");
});
});
Now that we have failed the test, we can make them pass by adding to our switch statement in our calculate function.
export default function calculate(
numOne: number,
numTwo: number,
operator: string
) {
switch (operator) {
case "+":
return sum(numOne, numTwo);
case "-":
return subtract(numOne, numTwo);
case "*":
return multiply(numOne, numTwo);
case "/":
return divide(numOne, numTwo);
default:
return "Invalid operator";
}
}
So far, so good, but what else can we test for this function?
What would happen if we entered an incorrect operator? Let’s add to our test.
test("should return an message if an invalid operator is used", () => {
// Arrange
const operator = "!";
const numOne = 0;
const numTwo = 0;
// Arrange
const result = calculate(numOne, numTwo, operator);
// Assert
expect(result).toBe("Invalid operator");
});
By chance, it looks like we accounted for that. YAY!🥳
What about if we try to pass an invalid number?
Let’s add the test to our “describe”:
test("should return 'ERROR' if NaN is passed in", () => {
// Arrange
const operator = "*";
const numOne = NaN;
const numTwo = 0;
// Arrange
const result = calculate(numOne, numTwo, operator);
// Assert
expect(result).toBe("Error");
});
Let’s go back to our calculate function and “Refactor” to accommodate this scenario by adding this code before the switch statement:
if (isNaN(numOne) || isNaN(numTwo)) return "Error";
Now, after saving, all our tests should pass.
By this point, we have a pretty solid calculator file to add to our index.ts file under the “TODO” section.
Let’s replace this line of code:
const total = 0;
to
const total = calculate(tempNumberOne, tempNumberTwo, currentOperator);
And make sure we add the import in:
import calculate from "./calculator/calculator";
Now, we are at our grand final of a complete testable file. Having functionality independent from our application allows us to move the “calculator.ts” file and “calculator.test.ts” file into any project with confidence that the functions should work anywhere.
BUT WAIT!!! THERE’S MORE!!
END-TO-END TESTING
Our final phase will be going over End-to-End Testing. I have already set the configuration up, so all we have to do now is just run the command to fire up Cypress.
npm run cypress:open
This will fire up Cypress.
For this test, let’s select “E2E Testing”.
Then select “Electron” and push “Start E2E Testing in Electron” to fire up another window.
And for this, we will change the path to “cypress\e2e\calculator.cy.ts” and then hit “Create spec”.
This will launch our new spec with a default template webpage.
From here, we need to update what we wanted to test: our application. In order to do that, we need to keep Cypress running and open a new terminal to start the application by running the command “npm start”.
Navigate to the “cypress/e2e/calculator.cy.ts” file and update the URL to visit our site.
We can add a test to this by mimicking what a user would do by clicking on our app.
describe("template spec", () => {
it("passes", () => {
cy.visit("http://localhost:8080/");
});
it("should sum two numbers when a '+' sign is given", () => {
cy.visit("http://localhost:8080/");
cy.contains("5").click();
cy.contains("+").click();
cy.contains("6").click();
cy.contains("=").click();
cy.get("#number-input").should("have.value", "11");
});
});
Conclusion
WOW! We did a lot for this blog. We went from understanding why testing is important to implementing Unit Tests, Integration Tests, and End-To-End Testing with Cypress. As always, this has been another black-box solution. Until next time!
Leave a Reply