Skip to content

Latest commit

 

History

History
126 lines (90 loc) · 12.2 KB

File metadata and controls

126 lines (90 loc) · 12.2 KB
← Previous Next →
Logbook UI and flows Go to index Deployment and monitoring

Testing

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.

Using ng-mocks effectively

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)

Testing client-side Firebase access

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.

Always test your security rules

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:

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.

The ConfigStore test file

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.

describe('ConfigStore', () => {
// See: https://github.com/help-me-mom/ng-mocks/issues/10217
// eslint-disable-next-line @typescript-eslint/no-deprecated
MockInstance.scope();
beforeEach(() => MockBuilder(ConfigStore, null).mock(ConfigService));
it('should create', () => {
const mockConfig: Config = { categories: ['test'] };
MockInstance(ConfigService, 'getConfig$', () => of(mockConfig));
const store = ngMocks.get<ConfigStore>(ConfigStore);
expect(store).toBeTruthy();
expect(store.status()).toEqual('connected');
expect(store.categories()).toEqual(['test']);
});
it('disconnects', () => {
const mockConfig: Config = { categories: ['test'] };
MockInstance(ConfigService, 'getConfig$', () => of(mockConfig));
const store = ngMocks.get<ConfigStore>(ConfigStore);
store.manageStream('disconnect');
expect(store.status()).toEqual('disconnected');
expect(store.categories()).toEqual([]);
});
});

  • 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 in ng-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 (whether null 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 whole ConfigService 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 the ConfigService's getConfig$ method.
    • Note how this is returning an observable of the config data we want to test with (using the special of RxJS operator).
  • We use ngMocks.get<ConfigStore>(ConfigStore) to get the instance of the ConfigStore 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 mocked ConfigService).
    • 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 now disconnected and no categories are available.

The EntryItemComponent test file

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.

@Component({
imports: [EntryItemComponent],
template: `<app-entry-item [entry]="entry" />`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
class TestComponent {
@Input() entry!: EntryDoc;
}
describe('EntryItemComponent', () => {
// See: https://github.com/help-me-mom/ng-mocks/issues/10217
// eslint-disable-next-line @typescript-eslint/no-deprecated
MockInstance.scope();
beforeEach(() =>
MockBuilder(TestComponent, [ConfigStore, EntriesUpdateStore]).keep(EntryItemComponent, {
shallow: true,
}),
);
it('should create', () => {
MockInstance(ConfigStore, 'categories', signal([]));
MockInstance(EntriesUpdateStore, 'processing', signal(false));
const mockEntry: EntryDoc = {
id: '1',
userId: '1',
timestamp: Timestamp.now(),
title: 'Test',
text: 'Test',
category: 'Test',
};
const fixture = MockRender(TestComponent, { entry: mockEntry });
const component = fixture.point.componentInstance;
expect(component).toBeTruthy();
});
});

  • We start by declaring a regular Angular component just for this test suite, naming it TestComponent, and importing the EntryItemComponent and using it in the template.
    • We declare an old-school @Input property for the entry input, which is then passed on the EntryItemComponent 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.
  • 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 the ConfigStore and EntriesUpdateStore (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 the EntryItemComponent is not mocked out, as we want to test the actual component. It's also important to set shallow: 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 the categories and processing state properties, from the ConfigStore and EntriesUpdateStore respectively.
    • We set up a test EntryDoc object to pass to the test component.
    • We use MockRender to render the TestComponent and provide the entry input.
    • We then test that the component instance exists.

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