Angular offers many things out of the box, including various testing-related functionalities. This presentation will demonstrate how we can build on Angular's solid unit testing fundamentals and apply certain patterns that make testing easier. Topics covered include: test doubles, testing module pattern, harnesses, "recipes" on how to test some common cases, and more!
Unit Testing Angular Applications
AI Generated Video Summary
This talk explores unit testing in Angular applications, covering topics such as testing front-end applications, specifics of testing Angular, best practices, and educational resources. It discusses the anatomy of a unit test in both Jasmine and Jest, the setup and initial tests in Angular, testing user interaction and event handlers, testing rendered output and change detection, and unit testing parent components with child components. It also highlights best practices like using test doubles, testing components with dependency injection, and considerations for unit testing. Code coverage is emphasized as a metric that doesn't guarantee bug-free code.
1. Introduction to Unit Testing Angular Applications
Welcome to my talk about unit testing Angular applications. Writing tests for your code is crucial for ensuring it runs correctly and does what it's supposed to do. We'll explore ways to test front-end applications, specifics of testing Angular, best practices applicable to other frameworks, and what to test, what not to test, and educational resources. For front-end testing, Jasmine and Jest are popular frameworks, while Cypress, Selenium, and Playwright are used for end-to-end testing. I'll focus on Jasmine for unit testing in Angular. Integration tests blur the line between unit and end-to-end tests. Angular mainly involves testing classes and creating instances. Let's explore Angular's tooling for testing and creating instances.
So quickly to go over the topics, we'll take a look at what are some of the ways to test front-end applications, what are some specifics to testing Angular applications, then in third part we will take a look at some best practices which are not really necessarily related exactly to Angular but they're also concepts that you can apply in other frameworks and in the end, we will have an overview of some of the things which you should test, which you shouldn't test, and what's the next steps, what are some educational resources.
Okay, so let's first take a look at what to test. If you go online, you will probably, and you Google testing, will probably see something related to the testing pyramid and it's what describes the ratio between unit integration and end-to-end tests. So some, in, let's say, the most common version of it, people say that you should have the most amount of unit tests, then a bit less of integration tests and then even less end-to-end tests. Now, there are variations of this, where some say that you should have something like this, which is like a testing hourglass, where you should have, let's say, equal amounts of unit and end-to-end tests, but a little less integration tests. And then you will also find this sort of shape, which is some kind of vase where you can put flowers in. And yeah, those are all kind of different philosophies, and that's all a topic on its own. We won't go into details about that. We'll just take a look at what matters for Angular.
And for front-end applications, you have usually a choice between Jasmine and Jest. Those are two most popular testing frameworks. With Jasmine, you would also use Karma as a test runner. Jest is kind of all-in-one solution. And Angular ships with Jasmine and Karma out-of-the-box, but it's also quite easy to use Jest with it. Then for end-to-end, you have things like Cypress, Selenium, Playwright. And these are all ways to run an automated browser where it's an actual browser running your code and you're simulating user behavior. So, I will be focusing on Jasmine today for unit testing. I will not be covering end-to-end or integration tests.
Now, integration tests, you can really do them with any of these tools because the line between end-to-end and integration and unit tests, it's kind of blurry. It depends on what you define as a unit and how many units are involved in a test. So, what is a unit in context of Angular? Well, Angular is mostly composed of different classes and those classes can be like components, directive, pipes, modules, services, etc. And then you also have functions like regular helper functions that you might have. So, mostly we are talking about testing classes in Angular and creating instances of those classes. So, let's take a look at some of the tooling which Angular gives us that makes testing and creating those instances a bit easier. So, this is the component which we will be working on.
2. Understanding Angular Components and Unit Testing
Angular consists of a class component with inputs and outputs for communication with parent components. We have a simple example with a button that increments a counter and emits an event to the parent. Let's explore the anatomy of a unit test in both Jasmine and Jest. It involves defining a test suite, setting up initial state, and writing individual unit tests.
It's quite simple. For those who are maybe not familiar with Angular, Angular consists of a class component that has inputs and outputs and these are the ways that the component can communicate with parent components. So, parent can pass something via input down to the child and the child can basically emit an event back to the parent using an output.
In our example we have a really simple component where you can see in the template on line 4 we rendered the amount of time that the button has been clicked and on line 6 we have a button with attached click handler that calls some method and that method is defined in the class and it just increments the counter and emits the event to the parent. So, this is one of the most basic components that you could have in Angular.
So, let's take a look at the anatomy of a unit test. And this is really the same in both Jasmine and Jest. There are some other differences between them but this is really the same and this is the same in many other languages I would say as well. So, first you would define a test suite. So, we are defining a test suite for counter component, we use describe function for that. Then we have some... We usually have before each. Before each is a piece of code which will run before each test, each individual test and here you would set up some state and some initial state. Then between lines four and six we finally have one individual unit test. It's using function it. It's a bit weird naming but it's called like that because the way you're supposed to read this is counter component, it should do something, so that's why the function is called it.
3. Angular Testing Setup and Initial Unit Tests
Let's expand on the basic scaffolding for the test by adding Angular-specific things. We have component and fixture, and we set up a testing module. We create the fixture and get the component instance. We have the first unit test for the Counter component, checking initialization and initial state. We test the rendered pre and button elements, and check the initial state of the counter.
Okay, so let's expand a bit on this basic scaffolding for the test by adding in some Angular-specific things. So here we have component and fixture. Component and fixture are related, so fixture is something that has some extra methods and helpers that make interaction with the component a bit easier in the tests and it allows us to query some stuff as we will see a bit later. And the component is the instance of the component class itself. So we will have references to two of those and we will be using them quite a lot throughout the tests.
And then on lines 11 through 13 we set up a testing module. So angular components are run inside of a module both in runtime and in tests. So here we are setting up a testing module inside which the application inside which the component will be running. Here we define any dependencies that the component might have. Here we're dealing with a really simple component which doesn't have any dependencies so we just import that component. Then on lines 15 and 16 we create the fixture using Create Component and we get the component instance. Line 17 is something quite specific to Angular, where we call Detect Changes. This is what triggers re-rendering. So in runtime when the application is running, change detection, which is the process which is in charge of re-rendering when necessary. It is let's say automatic but in tests you have to do it a bit more manually. Now finally between lines 20 and 22, we have our first unit test for our Counter component. We simply assert that the component is initialized, that the class instance exists. So it's a truthy value, the component.
Now let's test some initial state. So we will use our before each method to query the pre element and to query the button element. And we will store them in some variables in the test suite, because we will be using them in multiple places, so it's more reusable. So here we can see that we use fixture debug element query by CSS, and we get the pre element. We do the similar thing for the button, and for the pre element we also read the native element of it. Then finally on lines, now we have three unit tests, so we check that the rendered pre element exists, we check that the rendered button element exists, those are the first two tests, and in the final test we check that the initial state of the counter is set to one, and that's kind of just the baseline test to see that everything works initially. Now we want to check what gets rendered. It's quite important that you check what actually gets rendered as final HTML, because someone could delete the whole template of the component, and if you were only testing the component state, like checking the values of class properties, all your tests would still pass even if someone deletes the whole template. So that's why it's important to check what gets rendered, and that's why it's important to actually click on elements, etc. We'll see more about that. But yeah, here you remember we created our pre-element. It's an HTML element, and we just checked that the text content is times clicked one, and that's also the initial state.
4. Testing User Interaction and Event Handlers
When testing user interaction, it's crucial to trigger event handlers instead of calling handler methods directly. This ensures that the test fails if the button being clicked does not exist.
This is how it relates to the template that we have. Now just like we want to check what gets rendered, we also want to check what happens when we do user interaction, and now it's important when doing user interaction testing, you shouldn't be calling handler methods on the class directly. What you have to do is you have to trigger the event handlers. For similar reasons as why you want to check what gets rendered, someone could delete your template. If you're just calling the click handler directly, it will still pass, but if you're actually trying to click on the button and the button doesn't exist, the test will fail, okay? That's why it's important.
5. Testing Rendered Output and Change Detection
In unit testing Angular applications, it's important to manually trigger change detection after user interaction simulation. This ensures that the rendered output matches the expected values. By checking what gets rendered, you can ensure that everything is working correctly.
Yeah, so here on line seven and eight, we assert that the counter is initially one and the times clicked one is the rendered text. Then we click, then we check. So the way we click is we trigger event handler programmatically. This relates to the click handler that we attach, and this test passed. Now we check that the counter is two, but you will see that it seems like I have maybe a typo here. So I checked that the component counter is two, but the test passes and what is actually rendered is that the text is still times clicked one, it's not times clicked two. And this is because as I've mentioned, change detection, which is a process for re-rendering, it doesn't run automatically in tests like it does in runtime, when the application is actually running. This is why you have to call it manually after some user interaction simulation. So if you trigger some user interaction programmatically, you have to call detect changes to re-render. Now, the test will fail because times clicked one is not what actually gets rendered. What actually gets rendered is times clicked two. And you can see in the error log, you get like a nice message where it says what was the expected value and what was the actual value. Now we just fix it by updating one to two so now this test is valid. So two most important things here is you have to kind of know when you have to re-render in tests. It's a bit tedious, but you have to do it. And the second thing is you have to check what gets rendered in order to be fully sure that everything is working correctly.
6. Unit Testing Parent Component with Child Component
Now we are writing unit tests for our counter component. We test the template of a parent component, checking if it uses the child component correctly. We use a query by CSS to select the child component, get the component instance, and check that the counter input is set to 100. We can also test the parent component's reaction to the counter change event by console.logging the new value. We use Jasmine's spy-on to check if console.log has been called with the expected value.
Now we are writing unit tests for our counter component. But let's say this is, we are now looking at the template of a parent component. So we want to test like how is our parent component using this child component? And this is now a unit test of a parent component, where we can get the component instance of the child component. So here you can see we will use a query by CSS test.js counter and test.js counter is the selector of our child component. You can see in the template it's being used like that as an element selector. Then we get the component instance and we check that the counter input is set to 100. So we are basically checking that our template is doing what we are expecting it to do setting counter to be a 100 initially. We can test the other way around. So if our parent component wants to react on counter change event we can write tests for that as well. So let's say that on counter change we just console.log the new value in the parent component. This dollar event is a special piece of syntax but this will be the value with which the event is emitted. So in this case it will be a number that gets emitted on counter change and that's the value that gets passed to the handler method. So here the test is expanded a bit more because we are now using spy-on. So spy-on is something from Jasmine. It's very similar in Jest as well. So basically you take console.log and you spy that it has been called. So on line 3, we set up the spy and on line 5, we expect that it hasn't been called. On line 7, we trigger the event handler to, let's say, emit 101 as the new counter value, and then we expect the console.log has been called once with value 101. So it's important to check how many times something was called beforehand, how many times it was called after, and with which value it was called. And it's all kind of nicely... you usually want to check that something was called once and with some value, and that's why I have this nice method to have been called once with something. And this is how the event relates and how the event and handler name relates between template and the test.
7. Best Practices: Test Doubles
Test doubles are a best practice in unit testing Angular applications. When your component depends on a service that makes API calls, you can create a test double of the service. This test double, also known as a mock, has the same methods as the real service but returns mock data instead of making actual API calls.
Okay, so let's take a look at some of the best practices. Test doubles. So generally, if your application depends on some, let's say, service, and services in Angular are just classes that offer methods and they are most commonly used for fetching data. And here our user service is fetching... like making an API call and fetching the list of users. However, when you're unit testing, you don't really want to make API calls. And if your component depends on user service, you want to avoid making API calls. So, for that purpose, you would create a test double of user service. We usually call it here user testing service, but you could call it something like user service mock or something like that. And here you define all the same methods that the real service has, but it doesn't make a real API call, but it returns some mock data.
8. Testing Components and Best Practices
Angular uses dependency injection for injecting services in components. In unit tests, we provide a test double version of the service. The same approach can be applied to components. By using test doubles, we can test a parent component without integrating with its dependencies. Angular CDK provides harnesses, which offer extra methods for testing components. Coverage is a metric that shows how much of the code has been executed by tests. However, it doesn't include HTML templates and 100% coverage doesn't guarantee bug-free code.
And the way, yeah, and this is how it's used in some user table component, for example. So, Angular uses dependency injection. And this component would inject the user service. But when we're writing tests, we provide user testing service under the user service. So, the component still expects that it will get like user service instance. But when the tests are run, then you get the mock version, the test double, the testing version of the service, whatever you want to call it.
And you can do pretty much the same thing for a component. So, component also has some methods. It has some dependencies. You can create a testing version of the component, for which it's important that it has the same selector. But it doesn't have to have the complex template, and it doesn't have to have any implementation logic. It just needs to have all the inputs and outputs. And you do that very similarly. So, if your parent component depends on the counter component, in the unit tests for the parent component, you would import counter testing component. And that way you don't use the real component. You use a mock, and this is what makes it a unit test, not an integration test. So, you're not testing both components at the same time. You're not testing the parent component and the counter component at the same time. You're just testing the parent component and using a test double version of the counter.
Next topic is harnesses. Now, I won't go into harnesses in too many details, but they are something that's provided by Angular CDK, and you can define a harness for some component. And that harness will have some extra methods which are useful for testing. So, in an example of material checkbox, there you have a harness, which makes it easier to toggle the checkbox in tests, etc. But, yeah, I won't go into too many details. You can also write harnesses for your own components. Now, last topic about best practices is coverage. So, coverage is a metric which tells you, when you gather all of your tests, like how much of the code has been covered. And this is, you can get a nice-looking report like this, and if you take a look into one specific file, you can see which parts of the code have been executed and which have not. Generally, this is a good thing, but it's not something that you should really blindly chase, because coverage in Angular doesn't include HTML templates, so you won't get that in the coverage. 100% coverage, of course, doesn't mean you don't have bugs, and depending on the setup, some files might not be even included in the coverage, so you will get actually wrong numbers, and we have some documentation about that.
9. Unit Testing Best Practices and Considerations
Code coverage is often a lie, but project managers like it. Unit test business logic, services, core UI components, sensitive data handling, helpers, errors, and loading states. Test if something gets re-rendered correctly. Don't unit test third-party libraries or implementation details like private methods. Avoid testing complex interaction between units and complex UI flows. Unit tests are best for testing more complex code that could break. Consider the complexity of setting up unit tests compared to the value of the feature.
So, yeah, code coverage, it's often a lie, but that's a number that project managers like to hear about. Okay, so let me conclude some of these topics. So, what should you unit test? You should unit test business logic, services, core UI components, sensitive data handling, helpers, errors, and loading states, test if something gets re-rendered correctly, and anything really related to datetime handling and time zones, because it's really, really painful to debug, so having good unit tests for those things, that's really nice, but there are also some things which you shouldn't really unit test, and that's like third party libraries, your implementation details, and what I mean by this, you shouldn't like test private methods, you shouldn't be calling them directly, you should always test something in a way that it's used, so if you're testing a component, test it, write your unit test as if you were a parent component, don't go calling private methods. You also shouldn't test like complex interaction between units, that's like integration tests, that's like not for unit test, it's a different kind of test. Complex UI flows are also quite hard to do with unit testing, especially if it's like things like swiping, etc, so that's best left to end to end testing. If you have some like really trivial code, or some code that is here only temporarily, you won't really get too much benefit from unit tests, that code probably won't break. You want to test things which are like more complex, which could break, and you want to ensure that those don't break. And again, setting up unit tests can sometimes be quite complex. So if the time it takes to set up unit test is much greater than the feature is worth, then maybe consider skipping it and do some other type of testing.