DEV Community

Chris Cooper
Chris Cooper

Posted on

How To Write Simple Angular Integration Tests With Spectator

Integration tests are a vital tool in testing front-end code. While unit tests ensure that each component or service is doing what we expect it to do in isolation, integration tests ensure that all these individual parts are working together correctly. This great article by Kent C. Dodds argues that integration tests offer the highest return on investment and therefore should make up the majority of your tests.

Spectator is a library which wraps the built-in Angular testing framework to provide a simple but powerful API, resulting in reduced boilerplate and cleaner and more readable tests.

In this article I am going to show you how Spectator helps to write integration tests with ease.

If you want to jump straight to the code it's available on my GitHub and on StackBlitz.

Approach

When writing integration tests, I prefer to write tests which more closely resemble how the end user would use the app. What this means in practice is as follows:

  • Less need to test implementation details - only user interactions and outputs
  • Fewer mocks (this includes not shallow rendering components)
  • Longer tests with multiple assertions which more closely resemble a manual testing workflow

Sample Application

To demonstrate this approach I test a simple application which does the following:

  • On page load it fetches a list of posts from the JSONPlaceholder API and renders the response to the screen
  • While the data is loading it displays a progress bar
  • Once the data is loaded we can select a user from a dropdown which will filter the posts only for that user
  • We can type in a search box to filter based on a search criteria with a debounce time of 300ms

For the full code check out the StackBlitz but here's our PostsService:

export class PostsService {
  constructor(private dataService: DataService) {}

  private posts = new BehaviorSubject<Post[]>(null);
  private loading = new BehaviorSubject<boolean>(true);

  posts$ = this.posts.asObservable();
  loading$ = this.loading.asObservable();

  // call the DataService which is responsible for making HTTP requests and then update the posts and loading states
  load() {
    this.loading.next(true);

    return this.dataService.fetch().pipe(
      tap(response => {
        this.posts.next(response);
        this.loading.next(false);
      })
    );
  }

  // return an observable with the filtered posts
  getPosts(searchTerm: string, userId: number) {
    const filterFunction = (post: Post) =>
      (!searchTerm ||
        post.title.toLowerCase().includes(searchTerm.toLowerCase())) &&
      (!userId || post.userId === userId);

    return this.posts$.pipe(map(posts => posts.filter(filterFunction)));
  }
}
Enter fullscreen mode Exit fullscreen mode

I'm not going to go into the details here (I will show you that the implementation details don't actually matter for our tests) but the PostsService is responsible for storing our state and loading and filtering the posts data.

One important thing to point out though is that the app makes use of a DataService which makes the HTTP requests. It's a best practice to create a separate service for making HTTP requests anyway, but I find it also makes testing easier as it is simpler to mock the service than it is to mock Angular's HTTPClient.

Here's our PostsComponent:

export class PostsComponent implements OnInit {
  searchTermControl = new FormControl('');
  userFilterControl = new FormControl(null);
  posts$: Observable<Post[]>;
  loading$: Observable<boolean>;

  constructor(private service: PostsService) {}

  ngOnInit(): void {
    // request the posts data
    this.service.load().subscribe();

    // when the searchterm or the user filter changes get the filtered posts based on the filter
    this.posts$ = combineLatest(
      this.searchTermControl.valueChanges.pipe(
        debounceTime(300),
        startWith('')
      ),
      this.userFilterControl.valueChanges.pipe(startWith(null))
    ).pipe(
      switchMap(([searchTerm, userId]) => {
        return this.service.getPosts(searchTerm, userId);
      })
    );

    // loading state observable
    this.loading$ = this.service.loading$;
  }
}
Enter fullscreen mode Exit fullscreen mode

Test Setup

Firstly we need to install Spectator in our project.

npm install @ngneat/spectator --save-dev
Enter fullscreen mode Exit fullscreen mode

Whether you are using Jasmine or Jest, Spectator will work out of the box with no extra configuration, but for this example I will stick with Jasmine.

In posts.component.spec.ts we start by setting up our tests using Spectator's createComponentFactory function. Here is where we tell Spectator what component we are testing and import any modules the component requires. In our case the component relies on the FormsModule and the ReactiveFormsModule so we will add them to the imports array.

Here we would also declare any child components or any services that we need to provide, similar to how you would set up an ngModule, but in this simple case there are none.

// posts.component.spec.ts
describe('PostsComponent', () => {
  const createComponent = createComponentFactory({
    component: PostsComponent,
    imports: [FormsModule, ReactiveFormsModule],
    mocks: [DataService],
    detectChanges: false
  });

  ...
});
Enter fullscreen mode Exit fullscreen mode

When writing integration tests we need to decide which components and services will be covered in the tests and which we will mock. In this case I want my tests to cover the PostsComponent and PostsService. I don't want to test the DataService and therefore I add it to the mocks array. By doing this Spectator automatically mocks the DataService, converting each of its functions into a Jasmine spy (jasmine.createSpy()).

I don't want ngOnInit to run straight away because I first need to tell the DataService how to mock the fetch response, and so I set detectChanges to false.

Writing the first test

With this set up, we're ready to write our first test! It is going to test the following:

  • Initially we are showing the progress bar and no posts exist
  • Initially the user dropdown is set to 'All'
  • That once we receive our the posts data from the API the page shows the posts and is no longer showing the progress bar

Here's the test:

it('should load a list of posts for all users by default', fakeAsync(() => {
  // create the test component
  const spectator = createComponent();

  // get the mocked instance of the DataService
  const dataService = spectator.get(DataService);

  // mock the fetch function to wait 100ms and return 2 posts
  dataService.fetch.and.returnValue(
    timer(100).pipe(
      mapTo([
        {
          userId: 1,
          id: 1,
          title: 'First Post'
        },
        {
          userId: 2,
          id: 2,
          title: 'Another Post'
        }
      ])
    )
  );

  // run ngOnInit
  spectator.detectChanges();

  // assert that the progress bar is showing
  expect(spectator.query(MatProgressBar)).toExist();
  expect(spectator.query(byText('First Post'))).not.toExist();

  // get the user select element
  const select = spectator.query(
    byLabel('Filter by user')
  ) as HTMLSelectElement;

  // assert that it is showing 'All' by default
  expect(select).toHaveSelectedOptions(
    spectator.query(byText('All')) as HTMLOptionElement
  );

  // advance the time 100ms to simulate the HTTP request being made
  spectator.tick(100);

  // assert that the progress bar is not showing and that both our posts are showing
  expect(spectator.query(MatProgressBar)).not.toExist();
  expect(spectator.queryAll(MatListItem).length).toEqual(2);
  expect(spectator.query(byText('First Post'))).toExist();

  expect(dataService.fetch).toHaveBeenCalledTimes(1);
}));
Enter fullscreen mode Exit fullscreen mode

OK there's a lot going on here.

Firstly I'm executing the test in Angular's fakeAsync zone. This allows us to easily test asynchronous code in a synchronous way by controlling the passage of time.

If you want to understand more about testing asynchronous code in Angular, I thoroughly recommend checking out this post by Netanel Basel.

After creating my test component with the factory we created earlier, I mock the fetch method on the DataService. I'm telling it to return an observable which waits 100ms to simulate an HTTP request and then deliver an array of posts.

With this mock in place I call spectator.detectChanges() which is the equivalent of running ngOnInit() on the component.

At this point I want to assert that the progress bar exists, that my the posts are not showing and that the user dropdown is showing 'All', which I can do easily using Spectator's custom matcher toHaveSelectedOptions.

Then I advance the timer 100ms using spectator.tick(100) at which point the fetch method will have returned the list of posts.

Finally I can assert that the progress bar no longer exists, and that the two posts exist. I also assert that the fetch method has been called exactly once.

Notice a few things about this test:

  • Firstly I am not testing any implementation details. In this example I set up my app with a single PostsComponent and a PostsService, but as my app grew I might want to refactor this to use multiple components or a different method of managing state, for example including a state management library. What's great about this approach is that I can refactor my code and as long as my solution still called the DataService then my tests wouldn't need to change! 😄

  • Secondly I make multiple assertions. By doing this I avoid sharing state between tests which could be dangerous. What's more it isn't necessary to write a separate test for each assertion as modern test runners are able to tell us exactly what part of the test failed.

  • Overall my test closely resembles how a real user would use the app, or how a manual tester would test this functionality and therefore I can be confident in my code. Using Spectator's DOM selectors (e.g. spectator.query(byLabel('Filter by user'))) helps with this as this is how a real user would find the element, rather than by the element's id for example. It also implicitly goes some way towards testing accessability.

Filtering the posts

We still need to test filtering the posts so let's finish off by taking a look at a couple more examples and seeing how easy Spectator makes it.

When filtering by user my app uses a select input. For this I can use Spectator's selectOption matcher to select my option.

const select = spectator.query(
  byLabel('Filter by user')
) as HTMLSelectElement;

spectator.selectOption(
  select,
  spectator.query(byText('User 2')) as HTMLOptionElement
);
Enter fullscreen mode Exit fullscreen mode

Note that selectOption also runs detectChanges() after to reduce boilerplate even further. 👍

When testing the search term filter I need to simulate a user typing in the input. For that I can use the typeInElement helper:

const input = spectator.query(byLabel('Filter by title'));

spectator.typeInElement('first', input);

spectator.tick(300);
Enter fullscreen mode Exit fullscreen mode

Because I have a 300ms debounce time on the search input change, I need to run spectator.tick(300) to advance the timer by 300ms (the test must be run in the fakeAsync zone for this to work as discussed earlier).

Conclusion

Hopefully I have shown you how to write simple and readable integration tests using Spectator, which focus on user interactions and results rather than implementation details, and give you confidence that your code will work as expected for the end user.

For the full code check out GitHub or StackBlitz.

Resources

Kent C. Dodds - Write Fewer Longer Tests

Kent C. Dodds - Write tests. Not too many. Mostly integration

Netanel Basel - Testing Asynchronous Code in Angular Using FakeAsync

Top comments (1)

Collapse
 
offwork profile image
kerem

Are we only testing components? When it comes to Spectator, only component tests are mentioned. Just another good reason to learn.