Test-Driven development, or TDD, is a paradigm in which before any code is written, tests are first created based off of requirements/user stories. We can think of it similar to a “to do” list of things that a piece of software must do in order to satisfy the requirements. Throughout the development process, these tests are run in order to ensure that no breaking changes or bugs have been introduced.
A unit test is a smaller piece of the TDD paradigm in which it sets out to test a single unit of code – in most cases a function or method – to ensure that when it runs in a controlled environment the code behaves as we intended it to do so.
When we say unit testing as it relates to Angular, in most cases we’re talking about testing a single component to ensure that its functionality works as intended and no breaking changes have been introduced. (We can also unit test services, directives, and pipes) By default, Angular comes out of the box with Jasmine and Karma to assist with unit testing. Jasmine is a testing framework that gives us things such as spies and pattern matching, whereas Karma is a task runner that helps us with the execution of the tests.
When we generate a new component using the Angular CLI (ng generate component users) in addition to it generating our usual CSS, HTML template, and TS Class file, it also generates a .spec.ts file for us that scaffolds a basic test for us that ensures the component is at least successfully created. Certainly a great starting point!
Let’s take a look at what the CLI gives us after running the above ng generate command:
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { UsersComponent } from './users.component';
describe('UsersComponent', () => {
let component: UsersComponent;
let fixture: ComponentFixture<UsersComponent>;
//SECTION 1
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ UsersComponent ]
})
.compileComponents();
});
//SECTION 2
beforeEach(() => {
fixture = TestBed.createComponent(UsersComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
//SECTION 3
it('should create', () => {
expect(component).toBeTruthy();
});
});
I’ve added several comments to act as section headers to better assist in explaining what’s going on.
Section 1
This beforeEach block runs before each test we define later on in the spec file. We’re setting up a Test Bed, which is a controlled Angular environment for our tests to run in. Notice how the configureTestingModule method takes in an object as its argument and within that object we have a declarations property that is an array. Where else have we seen this? When ever we declare a module using the @NgModule decorator, of course! We’re essentially setting up a mini-module to test our component in!
Section 2
This second beforeEach block is where we’ll actually be setting up an instance of the component we want to test.
TestBed.createComponent(UsersComponent) returns to us a TestBed with an instance of the UsersComponent, and on the very next line we’re setting our component variable to fixture.componentInstance property which is an instance of our component that we can now run tests on!
fixture.detectChanges() gives us access to any changes to the component – it’s essentially refreshing the component after we’ve run a method or made some changes to some of the component’s properites.
Section 3
Finally! What we’ve been waiting for! Section 3 is where we’re actually writing our tests – Remember before each of these tests run, sections 1 and 2 will run each time – hence the beforeEach blocks. This boiler plate test lets us know that the component is created successfully without any bugs that are preventing it from compiling/loading.
Putting it all together
Let’s take things a step further. Let’s create a test for a login form that should only attempt to authenticate a user if the username and password fields are filled out. This is a great example because not only is it a real world use case, but it’ll also show us how to incorporate spies into our unit tests. Our login form also relies on a service, in this case the authService, so it’ll also give us a chance to look at how we incorporate services into our tests as well.
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FormsModule, NgForm } from '@angular/forms';
import { AppRoutingModule } from 'src/app/app-routing.module';
import { AuthService } from 'src/app/services/auth.service';
import { LoginComponent } from './login.component';
const authServiceStub = {
login: () => {},
signup: () => {},
user: {
subscribe: () => {},
},
};
describe('LoginComponent', () => {
let component: LoginComponent;
let authService: AuthService;
let fixture: ComponentFixture<LoginComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [LoginComponent],
imports: [AppRoutingModule, FormsModule],
providers: [{ provide: AuthService, useValue: authServiceStub }],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(LoginComponent);
component = fixture.componentInstance;
fixture.detectChanges();
authService = TestBed.inject(AuthService);
});
it('Should not attempt to authenticate user if the form is invalid', () => {
const authSpy = spyOn(authService, 'login');
const testFormData = <NgForm>{
valid: false,
value: {
email: '',
password: '',
},
};
component.onSubmit(testFormData);
fixture.detectChanges();
expect(authSpy).toHaveBeenCalledTimes(0);
});
});
A few important things to take note of that differ from the boiler plate code the Angular CLI gives us:
- We create an object called authServiceStub that stubs out all of the methods/properties that our login component will interact with. In the majority of cases it’s alright to just create no-op functions. We can run them, but we don’t expect anything to happen. We can spy on them to see if they do or do not run, and how many times if they do run.
- The login component relies on the AppRoutingModule as well as the FormsModule. These must be imported into our test module via the imports array.
- We need to provide the AuthService to this module, but we don’t want to test the logic within the service itself when unit testing this component. At this point we don’t really care how the service’s login and signup methods work. We just want to ensure that our login component actually runs them when expected. We’ll more or less overwrite most of its functionality via the useValue property.
- We inject the new mock AuthService into the TestBed so we can later interact with it and spy on its methods.
Lastly, let’s take a look at the single it spec from above:
it('Should not attempt to authenticate user if the form is invalid', () => {
const authSpy = spyOn(authService, 'login');
const testFormData = <NgForm>{
valid: false,
value: {
email: '',
password: '',
},
};
component.onSubmit(testFormData);
fixture.detectChanges();
expect(authSpy).toHaveBeenCalledTimes(0);
});
We’re doing a few important things here:
- We’re creating a spy. We’re going to spy on the login method from our newly created authService that we injected into this test bed.
- We’re setting up basic form data that we know should result in the login function not running.
- We attempt to run the component’s onSubmit function with the testFormData as an argument and we’re refreshing to get any changes.
- Finally, we’re checking to see how many times the login function was run thanks to the authSpy. We’re expecting this value to be 0. If it’s been run 1 or more times the test will fail and we know that we introduced breaking changes into our code. (Did we remove a conditional, perhaps?)
What about DOM elements?
The above example didn’t verify that an error message was being presented to the user, which would be somewhat of an awkward user experience if we just left them guessing as to why nothing is happening while using the application. How would we go about creating a unit test that also ensures that some kind of error message is being shown to the user? Let’s take a look at another it block:
it('Should display a div that contains an error message in the event of a user error during login', () => {
const testFormData = <NgForm>{
valid: false,
value: {
email: '',
password: '',
},
reset: () => {},
};
component.onSubmit(testFormData);
fixture.detectChanges();
const loginElement: HTMLElement = fixture.nativeElement;
expect(loginElement.querySelector('#error-container')?.innerHTML).toContain(
'Must provide valid credentials'
);
});
A lot of this is pretty similar to the previous example. The main difference is that after we attempt to submit the form with data that we know will cause it to fail, we run fixture.detectChanges() to “refresh” the component, and then we access the underlying HTML element via fixture.nativeElement. We then create an expect/toContain check that accesses the HTML element that has error-container as its ID and ensures that its innerHTML contains the string “Must provide valid credentials”.
Wrapping things up
Overall, unit testing can be challenging, but as an application grows and development teams have to move faster, it speeds up the overall development process and decreases errors going into production because you can immediately check to see if the changes you are making have unintended consequences elsewhere in the application. It’s also important to note that as a best practice, it’s best to write the majority of tests before writing any code in order to ensure you’re not introducing biases – It is test driven development, afterall!