Introduction
Unit testing plays a crucial role in software development by ensuring code quality and reducing bugs. Unit tests are automated tests that verify the behavior of individual units of code, typically at the function or method level. Developers may quickly detect and fix bugs by running unit tests regularly throughout development, resulting in more robust and maintainable code.
Check this article to learn more about unit testing and how it can benefit you.
Here, we will explore the advantages of unit testing, the tools and frameworks commonly used for writing and running unit tests, and some best practices for effective unit testing.
What is Karma?
Karma is a popular test runner used for running unit tests in Angular. It is a tool that allows developers to test their code in a browser environment, which makes it easier to simulate user interactions and test complex functionality.
It provides a range of useful features, such as code coverage and test debugging.
What is Jasmine?
Jasmine is a popular open-source testing framework for JavaScript that provides a clean and easy-to-use syntax for writing unit tests. It provides a suite of functions for writing assertions, mocking objects, and organizing tests into logical groups.
Jasmine is often combined with other tools, such as Karma. Jasmine and Karma work together to provide a powerful and flexible platform for writing and executing unit tests in Angular applications.
Getting started
I initialized a usual angular application using the command: ng new <<name of your app>>
Once completed. I opened it in my IDE, in my case WebStorm and run the npm install command.
If the readme.md file is not opened, open it. You will see the following:
Note the section: 'Running unit tests'. It provides the command you need to run to execute the test in Karma.
Expand the project structure as follows: src > app
The app.component.spec.ts will contain the unit tests for the app component. As a rule of thumb, each component created will have a 'component.spec.ts' containing some basic test. Its naming is as follows: <<name of the component>>. component.spec.ts.
When you open the 'spec.ts' file, you will see the following:
import { TestBed } from '@angular/core/testing';
import { AppComponent } from './app.component';
describe('AppComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [
AppComponent
],
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it(`should have as title 'basic-angular-app-with-tests-demo'`, () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app.title).toEqual('basic-angular-app-with-tests-demo');
});
it('should render title', () => {
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('.content span')?.textContent).toContain('basic-angular-app-with-tests-demo app is running!');
});
});
-
On line 4, you will see 'describe'. It can be considered as a container regrouping test for the same function. The tests provided by default are used to test the creation of the component. It is good to keep them.
-
On line 5, there is the 'beforeEach' function. These are some shared codes/setup that need to be run before each test in the 'describe' part.
-
On lines 13, 19, and 25, we see the 'it'. It is the actual unit test that tests a specific scenario. It is often referred to as a spec. We see that there are 3 'it'/spec in this describe. It means there are 3 unit tests covering 3 specific scenarios in this function.
Each spec has 2 parameters. The first is the expection, which is what we expect the test to do. It is simply a string describing what is expected from the test; for example, 'should return the text xyz'. The second parameter is a function which is for instance initializes some mock variables or do some set up to call an 'instance' of the real function that does the actual processing. What is inside this function depends on your function in your JavaScript file and its use case.
-
On lines 16, 22, and 29, we see 'expect'. After the test is run in the function of the 'it', the 'expect' checks if the result of the test matches the expectation(its expected value). For example, on line 22, the line 'expect(app.title).toEqual('basic-angular-app-with-tests-demo');' check if the app.title obtained as a result of the test matches the value' basic-angular-app- with-tests-demo'. If it does match this expectation, the test is successful. Else the test is a failure and is marked as so.
The karma.conf.js file
This file contains all the configurations required to run unit tests and tests with coverage. It is generated automatically when you create an Angular project.
One important line is line 28. It shows where the coverage report is stored when you run the tests with coverage.
If you do not want any browser window to open when the tests are run, change the config on line 40 from 'Chrome' to 'ChromeHeadless'. The line will be as follows:
browsers: ['ChromeHeadlessNoSandbox'],
Once done, add these configurations between lines 40 and 41:
customLaunchers: {
ChromeHeadlessNoSandbox: {
base: 'ChromeHeadless',
flags: ['--no-sandbox']
}
},
Let's run the default test
1. To run only unit tests, run the following command in the terminal: ng test
2. To run a test with coverage, run the following command in the terminal: ng test -- code-coverage
When we navigate to the folder indicated on line 28 of the karma.config.js file, we will see a folder with the same name as our Angular project. When we go to this folder, we will see some auto-generated files. To view the coverage report, open the index.html
Writing our own unit test
Let's write a function first based on the following requirement: Write a function to calculate the area of a rectangle.
We go into our .ts/.js file and write the function:
calculateRectangleArea(length: number, width: number): number {
return length*width;
}
The app.component.ts file looks like this:
Let's open the app.component/spec/ts.
Below is a test I have written to test the 'calculateRectangleArea':
describe('calculateRectangleArea', () => {
let component: AppComponent;
let fixture: ComponentFixture<AppComponent>;
beforeEach(() => {
fixture = TestBed.createComponent(AppComponent);
component = fixture.componentInstance;
})
it('should return a positive value for area', () => {
const theArea = component.calculateRectangleArea(5, 4);
expect(theArea).toBeGreaterThan(0);
})
})
We start by creating a 'describe' containing the test for the function 'calculateRectangleArea'. We declare two variables: component and fixture.
We create a beforeEach function that will initialize the values for component and fixture before executing each spec. For more info about testbed and fixture, you can check out this article.
Please note that these steps are very often standard and are required to run the tests for your functions.
The 'it' or spec contains our test. In the second line in the 'it', we call calculateRectangleArea(). It is actually a mock call.
On the second line of the spec, there is the 'expect'. The line means that our test is expecting the area that was evaluated just before is greater than 0, that is, positive.
Considering the fact that in the function, the value of length and width is 5 and 4, respectively, we run the test using ng test command:
We can also run the test with coverage. Note that you need to delete the previous folder generated by the last time you ran the test with coverage.
So far, everything works well. Now, let's change the value of the length in the function. Let's change it from 5 to –5. This is an action that a QA or a user can do. As general knowledge, we all know that area cannot be negative. So we always expect our test to be always successful.
Once the value of length has been changed, let's run the test again.
Our test has failed because the value of the area is -20. This happens because the new length is -5, and -5*4 is -20.
As a rule of thumb, we should fix the code wherever possible and not the test.
To fix the code and prevent this situation does not happen again, we made the following changes:
If 0 is returned, we will know there has been an error and indicate an erroneous value. This can be used to filter out this kind of result. The logic for calculating area is wrapped around a check to verify that length and width are greater than 0.
Once these changes are done, we can add new test cases to cover these. I have added a new test to check for at least one negative input:
it('should return 0 if at least one negative input is provided', () => {
const theArea = component.calculateRectangleArea(-5, 4);
expect(theArea).toEqual(0);
})
When you run all the tests, you get the following result:
Note that the value for branches is no longer 0 but is now 100%. Branches represent the if condition.
All if conditions have 2 possibilities, either the if or the else. Covering only the if or only the else will give 50% coverage.
In our case, the 2 tests covers the if and the else; hence giving 100% branch coverage.
If we now run all the tests with coverage and open the result from the index.html file, we will see the following:
When we click on 'app.component.js', we can see more details:
On line 12, we see '2x' in green. It means that the test suites has passed on this line twice.
Once when it was covering the if and the second one when it was evaluating the condition before determining to go in the else.
Moreover, since it is a small function and that it does one important and specific task, our test covers all the aspects with 100% coverage.
Conclusion
We have started our exploration of unit tests with this basic example. There are more techniques that can be used depending on your code. We have seen how unit tests have helped us capture bugs early and mitigate it. It has also helped us refactor our code to prevent other unforeseen situations. Despite taking a bit more time, unit tests have enabled us to deliver quality code and features.
Now, you can try it out and share in the comments how it is going for you!