Testing Concepts
Some key concepts to creating effective tests
PROModule Outline
- Source Code & Resources PRO
- Lesson 1: Introduction PUBLIC
- Lesson 2: Introduction to Test Driven Development PUBLIC
- Lesson 3: Testing Concepts PUBLIC
- Lesson 4: Jest and Cypress PRO
- Lesson 5: A Simple Unit Test PRO
- Lesson 6: A Simple E2E Test PRO
- Lesson 7: Introduction to Angular's TestBed PRO
- Lesson 8: Setting up Tests with Jest and Cypress PUBLIC
- Lesson 9: Test Development Cycle PRO
- Lesson 10: Setting up Cypress & Jest in an Ionic/Angular Project PRO
- Lesson 11: The First Tests PRO
- Lesson 12: Injected Dependencies & Spying on Function Calls PRO
- Lesson 13: Building out Core Functionality PRO
- Lesson 14: Testing Asynchronous Code PRO
- Lesson 15: Creating a Mock Backend PRO
- Lesson 16: Setting up the Server PRO
- Lesson 17: Testing Integration with a Server PRO
- Lesson 18: Testing Storage and Reauthentication PRO
- Lesson 19: Refactoring with Confidence PRO
- Lesson 20: Conclusion PRO
Lesson Outline
Testing Concepts
Automated tests have been around for a long time, and there is a lot of literature on testing concepts and best practices. You could read entire books purely on testing concepts (I found this book particularly helpful). Although a specific language may be used to illustrate tests, the same concepts apply no matter what language you are using (you won't find any Javascript in the book I linked).
As important as understanding these concepts will become as you learn to test your applications, I don't want to get too bogged down in theory right away. I want to keep this module as practical as possible, but there are a few testing concepts that I think we do need to understand right out of the gate.
In this lesson, we will cover a few core testing concepts that are going to help us create and maintain our tests.
NOTE: I mentioned people generally have a lot of opinions about testing, and often there is no one "true" or "best" way to do it (although there are certainly justifications for some approaches being better than others). I am going to start mentioning things that you "should" be doing, but just keep in mind that this is also my own view on testing best practices. When I say that you should be doing something, it doesn't necessarily mean that is the one way that you have to do it. There might be special circumstances where it makes sense to break certain rules, or there might be a different approach in general that works better for you or your situation.
AAA: Arrange, Act, Assert
This is perhaps the most important concept to understand, it is the guiding principle for how we will create our tests. When creating a test, there are three distinct steps:
- Arrange
- Act
- Assert
It's actually quite simple to think about. First, we arrange our test by getting the test into the state it needs to be in to perform the test, then we act by executing some code, and then we assert that something specific has happened. To put it less formally:
- Set it up
- Do something
- Check that what you wanted to happen actually happened
Let's take a look at our example test we created with vanilla Javascript and see how this concept would apply to that:
// Test that `incrementTotal()` increases the total by 1
/* Arrange */
let myTestObject = new SomeObject();
/* Act */
let oldTotal = myTestObject.getTotal();
myTestObject.incrementTotal();
let newTotal = myTestObject.getTotal();
/* Assert */
if (newTotal === oldTotal + 1) {
console.log('test passed!');
} else {
console.log('test failed!');
}
First, we arrange the test by setting up the object we are testing, then we act by accessing some methods on that object, and then we assert that the newTotal
should be equal to oldTotal + 1
.
It can also be a little hard when you are actually creating tests to know what to do for this "act" step. However, there is a reasonably simple way to think about that too. Whenever you are testing something, you are generally going to be testing one of these three scenarios:
- Testing what happens when something is initialised (e.g.
let myService = new MyService();
) - Testing what happens when a method is called (e.g.
myService.someMethod()
) - Testing what happens when an event is triggered (e.g. a
click
event)
Our tests will get a little more complicated later, and we won't be writing them with vanilla Javascript like this, but the concept will remain the same: Arrange, Act, Assert.
One Assertion Per Test
When creating tests, you should generally only have one assertion per test - your test should only be testing one specific thing. This is not strictly enforced, you can easily have multiple assertions in a single test if you want, but in general, it's not a good idea.
Let's take a look at a test that would violate this rule (I will substitute the actual implementation with comments for now):
it('should allow todos to be modified', () => {
// trigger edit todo functionality
// expect that the edit page was launched with the todo
// trigger delete todo functionality
// expect that the todo was passed to the delete function in the data provider
});
NOTE: The test structure you see above is how we will structure tests throughout this module. I will introduce you to this concept in the next lesson, but I will use a couple of examples using this pseudo code structure throughout this lesson to illustrate some points.
We're testing if todos
can be modified, but we're really testing two things here: can todos
be edited, and can todos
be deleted. The reason that this isn't a good structure is primarily that:
- It is vague - "should allow todos to be modified" is broad and it isn't immediately clear what this will be testing
- If the test fails, we don't immediately know what is wrong - is the edit functionality failing, or is it the delete? or are both failing? If these were separate tests then it would be more immediately obvious.
A better structure might look like this:
it('delete function should pass the todo to the deleteTodo method in the provider', () => {
// trigger delete function on the page
// check that the deleteTodo method on the provider has been called
});
it('edit function should launch the EditTodo page and pass in the todo as a parameter', () => {
// trigger edit function on the page
// check that the navCtrl pushed the EditTodo page with the `todo` as a parameter
});
These tests are more well defined and they run independently of each other. Don't worry about making your test descriptions too long, the more verbose the better!
Make Tests Independent
In the previous example, we touched on the concept of tests running independently of each other. In that example, since we were running what should have been two tests inside of the same test, there's a chance that the first thing we are testing could have changed the behaviour of the second thing.
Even when we don't test multiple things in the same test we can still run into issues with this, consider this example:
let groceryList = new GroceryList();
it('we should be able to push items to the grocery list', () => {
// push an item to the grocery list
// check that it was added
});
it('the grocery list should be empty by default', () => {
// check that the grocery list is empty
});
In this case, our first test is going to interfere with the second. By default, our grocery list may be empty, but if the first test adds an item to it then it is no longer in its default state. To combat this, we need to make sure we "reset" before running each test, typically this will look like this:
let groceryList;
beforeEach(() => {
groceryList = new GroceryList();
});
it('we should be able to push items to the grocery list', () => {
// push an item to the grocery list
// check that it was added
});
it('the grocery list should be empty by default', () => {
// check that the grocery list is empty
});
We've added a beforeEach
function that will run before every test. We will discuss how this works in more detail in the next lesson, but basically, it is just creating a new GroceryList
every time we run a test, rather than just creating it once at the beginning and having all tests share that same instance.
Isolate Unit Tests
In the one assertion per test example above, we are testing that the deleteTodo
method in the provider is being called. We are not testing that the todo is actually deleted.
In this case, todo deletion is not the job of the page, it's the job of the provider that handles todos. The job of the page is just to pass that information on to the provider, so that's what we test. We would have a separate unit test associated with the provider to test that the deletion process occurs correctly there, whatever that may be.
In a unit test, the code that we are testing should be entirely cut off from the rest of the application. It should not have data passed into it, it should not receive data from a server, it should not send data to a server, and it should not make calls to anything outside of the thing that we are testing.
If the functionality we are testing relies on making calls to a server, or having data passed into it (e.g. data being passed in through a Todos
service) then we "fake" it by using a "test double" or "mock". We are going to discuss this in more depth, but the basic idea is that if you need to pull in data from Todos
to perform a test then you don't actually pull data from Todos
, you make your own fake implementation of Todos
to pass test data in. If you need to receive a response from a server, you don't make a request to the actual server, you intercept the request and respond with your own fake data.
This can be hard to understand initially, we want to test that our application works - why would we completely circumvent a call to the server and return data that we know is correct? It's not the role of a unit test to test integrations with other components and services, the only thing we are interested in with unit tests is testing the unit itself. Unit tests are about testing isolated chunks of code. We can capture and test broader system level behaviour (e.g. how the application behaves as a whole) with our E2E tests.
Don't Try to Test Everything
Let's imagine that we are unit testing a function we wrote that tells us whether or not a specific number is even. The test might look something like this.
it('isEven function should return true for even numbers', () => {
expect(isEven(4)).toBe(true);
});
it('isEven function should return false for odd numbers', () => {
expect(isEven(5)).toBe(false);
});
These tests will do what they are supposed to do, but you might think "just because it works for those numbers it doesn't mean it works for all numbers". Along that line of thinking, you might want to make your tests a little more robust:
(don't do this)
it('isEven function should return true for even numbers', () => {
for (i = 0; i < 2000; i += 2) {
expect(isEven(i)).toBe(true);
}
});
it('isEven function should return false for odd numbers', () => {
for (i = 1; i < 2000; i += 2) {
expect(isEven(i)).toBe(false);
}
});
Now we're not just testing one odd number and one even number, we're testing 1000
- surely that's better right? Why not 10,000
? 30,000
? 1,000,000
? If we were testing a function that checked if a particular name exists in a phone book, do we pull in a list of 10,000
test names and test all of those?
It's not possible to cover all cases and we shouldn't try, your tests would be extremely slow if you took this approach. A test is not a rigorous proof that our code works, in fact, it's impossible to test that code is 100% correct.
Instead, we focus on interesting or representational cases. Maybe we test a few numbers that we know to be even, and a few numbers that we know to be odd. In the case of checking names maybe we test with a couple of valid names, a couple of invalid names, maybe even some numbers to attempt to throw a spanner in the works.
This is a case where maybe it's ok to break the rules a bit. You probably don't want to write 5 independent tests to test the same thing for different values, so perhaps your isEven
test looks like this:
it('isEven function should return true for even numbers', () => {
expect(isEven(0)).toBe(true);
expect(isEven(4)).toBe(true);
expect(isEven(987239874)).toBe(true);
});
In general, don't use loops in your tests.
Mocks and Spies
Previously, we discussed the concept of "faking" functionality to isolate our unit tests. This is where mocks and spies come in.
Mocks
A mock replaces the object under test with a fake or dummy implementation of it. Before, we talked about the scenario of a test that relied on receiving data from Todos
. In the code that we are testing there might be something like this:
const myValue = this.todos.getById('12');
If we are testing the DetailPage
but this data is supplied by a separate service, then we run into an issue with our unit test, because we can't rely on data coming in from another component/service.
Instead of using the actual Todos
service, we can switch it out in the test setup (you will see an example of how this is actually done in the lesson on TestBed) with our own fake implementation, i.e.:
class myFakeTodos {
public getById(id: string): any {
return {
title: 'hello!'
};
}
};
This gets rid of the functionality of the real Todos
and replaces the getById
function with a function that will just return an object with a title of hello!
for anything passed into it. Once we have our fake version, we can just tell the test to use the fake version instead of the real version when we are setting up the providers:
providers: [
...
{ provide: Todos, useClass: myFakeTodos },
...
]
NOTE: This is done in the test set up, which we will get to shortly, don't replace your actual provider in your application with this.
You can also use the built in jest.fn()
to create a mock object for you (which comes with spies built in, which we will get to in a moment). Instead of using a class and supplying it to useClass
we could instead do this:
providers: [
{
provide: Todos,
useValue: {
getById: jest.fn(),
},
},
];
or if we needed our getById
method to still return a specific value we could do this:
providers: [
{
provide: Todos,
useValue: {
getById: jest.fn().mockReturnValue('hello!'),
},
},
];
Now whenever we reference Todos
in our test, it will instead use that fake implementation we created.
Spies
A spy is conceptually different to a mock, but with Jest they come bundled together. If we mock an object with jest.fn()
it will also function as a spy. We can also set up a spy directly on an objects method in our test like this:
jest.spyOn(someObject, 'someMethod');
you can also chain on a value for it to return in the test if you like:
jest.spyOn(someObject, 'someMethod').mockReturnValue(42);
With a spy, not only will the object be replaced by a fake implementation of that object, but we can now also easily track any calls that are made to it. The spy above will tell us whether or not the someMethod
method was called at any point during the test, what information it was called with, how many times the function was called, and so on.
This is something we will be doing frequently in our tests. For example, in a test that involves logging the user in we might want to spy on the NavController
and check that its navigateRoot
method was called at some point. If we were testing whether a photo could be deleted, we might want to spy on a PhotoService
and check that its deletePhoto
method was called with the correct photo data as a parameter. Using jest.fn()
or jest.spyOn
allows us to both mock these services (so that our test is isolated and not actually calling methods on these services) and to check that the appropriate methods were called with the correct data.
In the next lesson, we are going to explain these concepts in more detail, and the framework that we use to implement them - for now we are just focusing on the general concepts.
Tests Are Not Fool Proof
Finally, keep in mind that tests are not fool proof. A passing test does not guarantee that your code works. If you have written your tests well it is a strong indicator that your code is doing what you want it to, but it is no guarantee. It's always possible that the test was written incorrectly, or it doesn't cover a specific case that causes a failure.
Summary
Testing is one of those things where there isn't really a "one true way" to do things, which makes it a bit more complicated to wrap your head around. There are certainly some guidelines and best practices around what to test and how to test it, but there are different schools of thought and not everybody agrees on what these best practices are (and it is a topic that people can have rather passionate opinions on). Writing tests is basically the same idea as just coding software generally - instead of coding functionality for our application we are coding tests. There is no "one way" to code and there is always competing paradigms and best practices, so it makes sense that the same debates happen in the testing space.
Do your best to test with what you know now, and slowly build on your knowledge. Don't worry about getting it perfect right away. If you had to wait until you understood how to code "properly" before writing your first program, you probably wouldn't be here right now.