← Previous | ↑ | Next → |
---|---|---|
Logbook UI and flows | Go to index | Deployment and monitoring |
In this simple example app we've placed a lesser emphasis on having comprehensive automated tests — we have some basic tests that check that various components and services can be instantiated without failing, and we do comprehensively test the security rules.
This isn't to say that automated testing is not important — it can be vital to the long-term growth and necessary refactoring of an app (especially if you have good test coverage of the important flows and app logic). In an ideal situation we would have a comprehensive suite of tests covering all aspects of the app. In reality, we have to balance the time and effort spent on testing, especially at very early stages of a project when the concept and idea is still forming, and we need a much faster iterative approach closer to functional prototyping. Once the app is more mature, then having comprehensive tests becomes more important, and very useful in the longer run, though still requires a judgement call on where to spend the most time testing.
Note
You may be familiar with test-driven development (TDD) or behavior-driven development (BDD) — these can be useful methodologies to follow when building your app. Whilst we don't directly promote using these methodologies in the example apps they are worth looking into if you're not already familiar with them. Our advice is to be pragmatic: if you're familiar with TDD/BDD and you find it helps you to flesh out the implementation of your app as you go along then by all means use it. But if you're not familiar with these methodologies, or you find them too restrictive, then don't feel like you have to follow them. As long as you introduce automated tests at some point in your app's development and growth (and maintain them!), you're on the right track.
In this document, we'll cover some basic tips and gotchas to be aware of when writing tests for this tech stack, and go over a couple of the more involved test files in this simple example app.
As a reminder, from the base template, we've chosen to use ng-mocks as much as possible (for the app test suite) to reduce the boilerplate that comes with writing tests for Angular components, services, directives, etc, and to mock out dependencies in an easier way.
Here are some patterns to follow:
✅ Pattern |
---|
Use MockBuilder as much as possible to set up your test suites. It's a bit of a kitchen sink and can do a lot of things for you.It can automatically mock out services and components that are used in the component you're testing (though it will null the internals of these so you still need to provide the mock implementations when needed). |
✅ Pattern |
---|
Use MockInstance to provide targeted mock implementations and spies for methods and properties of services and components.Do this per test case (i.e. within the it block).Don’t forget to add MockInstance.scope(); at the beginning of the test suite if you’re using MockInstance anywhere in that test suite. This will ensure that any mocked out bits only apply to that particular test suite. |
✅ Pattern |
---|
Use MockRender to render components and provide inputs.Note: you will still need to wrap your components in a TestComponent wrapper to provide inputs and outputs (since it's not currently possible to easily test signal inputs and outputs with MockRender ). See below for a concrete example. |
✅ Pattern |
---|
Use ngMocks.get to get the instance of a service. You may often need to supply a generic type param to make this strongly typed (and prevent ESLint from complaining), especially when fetching stores — e.g. ngMocks.get<EntriesStore>(EntriesStore) |
Avoid having test suites for the data services that wrap Firebase functionality (e.g. the ConfigService
). This is because it's hard to mock the Firebase JavaScript SDK functions and not worth the effort to test these data services directly, as long as the logic within them is simple enough.
Tip
If you find the logic within these data services are getting more complex, then you should consider breaking them out into separate services or modules that you can test on their own.
Instead, use manual testing to test that actual Firebase usage works, or write an integration or end-to-end test.
Note
This is different to the store tests, which we do want and where we mock the whole data service so that we’re not accessing Firebase services in these unit (or partial integration) tests.
Note
The base template does come with service tests for the services that wrap Firebase Auth (e.g. AuthService
and LogoutService
) but only because it’s easy to mock Firebase Auth in these simple cases.
As we covered in a previous document, one of the first things we did (once we understood our data model) is to define the security rules (in this case for Firestore and Realtime Database) and write tests to cover all the possible scenarios.
You can find these in:
firebase/test/firestore/firestore-rules.spec.ts
— for Firestorefirebase/test/rtdb/rtdb-rules.spec.ts
— for Realtime Database
These files (and the whole test set up) is already provided by the base template — we just added our specific test cases.
Tip
Use the testEnv.withSecurityRulesDisabled(…)
function to set up any test data beforehand.
Note
You'll notice that the test cases we've added for the security rules are bigger and more narrative (i.e. work on a single set of data step-by-step, making assertions along the way). Some folks prefer to have a maximum of one assertion per test case. Whilst this is a good ideal to aim for, we've found that it can sometimes lead to a lot of boilerplate and repetition. So we aim for more narrative test cases that do more but on a single set of data and keeping within a logical concept. For example, we've chosen to have one big test case to test reads, writes and deletes to the entries
collection. Splitting this up is possible, into separate test cases for each type of operation, and may well be your preference.
This is a similar situation to writing any code: the structure and understanding may take time to settle and mature, and it may only be worth breaking things up and refactoring when it does.
Let's take a look at the ConfigStore
test file as an example of how to test a store service, and how to use ng-mocks
to simplify the process.
MockInstance.scope();
is used at the beginning of the test suite to scope the mocked instances to just this file.MockBuilder
is used to set things up for the Angular testing environment.- The first argument is the service being tested.
- Note how the second argument is
null
— this is a quirk inng-mocks
in order to have it run in strict mode — this isn't always necessary, but it's good to get into the habit of always providing this second argument (whethernull
or an array of direct dependencies to provide and mock out). - We can then chain on
.mock
calls to mock out specific dependencies of this service (which ng-mocks is not aware of, e.g. because they are provided globally) — in this case we want to mock out the wholeConfigService
since we don't want to actually access Firebase services.
- In both test cases, we need to set up the config data that the mocked
ConfigService
will return.- We do this using
MockInstance
to provide a mock implementation of theConfigService
'sgetConfig$
method. - Note how this is returning an observable of the config data we want to test with (using the special
of
RxJS operator).
- We do this using
- We use
ngMocks.get<ConfigStore>(ConfigStore)
to get the instance of theConfigStore
to test.- Note how we're providing a generic type parameter to make this strongly typed.
- In the first test case, we just want to check that we have a valid instance, and that it's in the correct state (
connected
) and with some categories (from the mockedConfigService
).- Recall that we connect the stream when the store is instantiated, so the test doesn't need to explicitly trigger the connect.
- In the second test case, we want to test that disconnecting the stream works, so we explicitly call
store.manageStream('disconnect');
and check that the state is nowdisconnected
and no categories are available.
Let's take a look at the EntryItemComponent
test file as an example of how to test a component with a TestComponent
wrapper, and how to use ng-mocks
to simplify the process.
- We start by declaring a regular Angular component just for this test suite, naming it
TestComponent
, and importing theEntryItemComponent
and using it in the template.- We declare an old-school
@Input
property for theentry
input, which is then passed on theEntryItemComponent
in the template. - Note: this is because
ng-mocks
doesn't currently support signal inputs, and also this is how Angular themselves test signal inputs for components.
- We declare an old-school
MockInstance.scope();
is used at the beginning of the test suite to scope the mocked instances to just this file.MockBuilder
is used to set things up for the Angular testing environment.- The first argument is the component being tested.
- The second argument is an array of the dependencies that should be both provided and mocked out. The reason why we're doing this and not chaining the
.mock()
function is because both theConfigStore
andEntriesUpdateStore
(which are used by the component we're testing) are feature-level stores that need to be provided somewhere first before they can be injected into the component. (In the running app, these are provided at the lazily-loaded route level, which doesn't exist in this particular test suite). - Important: we chain
.keep(…)
here to make sure theEntryItemComponent
is not mocked out, as we want to test the actual component. It's also important to setshallow: true
to get this to work properly.
- We have one test case that checks that the component can be instantiated without failing.
- We again use
MockInstance
to provide mock implementations for thecategories
andprocessing
state properties, from theConfigStore
andEntriesUpdateStore
respectively. - We set up a test
EntryDoc
object to pass to the test component. - We use
MockRender
to render theTestComponent
and provide theentry
input. - We then test that the component instance exists.
- We again use
Tip
In the TestComponent
wrapper, don’t rely on initializing default data for inputs (in the property declarations within the TestComponent
) — instead you should pass them in when using MockRender
so everything is explicit and clear.
← Previous | ↑ | Next → |
---|---|---|
Logbook UI and flows | Go to index | Deployment and monitoring |