Request for testing cookbook @zewa666

As per @zewa666 suggestion, I am opening a topic for a request for a “cookbook” of help for someone new to unit testing. Some of these issues I’ve worked out by myself, and quite a lot of them are not aurelia specific - so this would be a general cookbook for testing.

Taking the following class as an example - fetch-service

import { HttpClient, json } from "aurelia-fetch-client";
import { autoinject, Container } from "aurelia-framework";
import { Router } from "aurelia-router";
import { Reducer, Store } from "aurelia-store";
import { log } from "../core/log";
import { IFetchRoute } from "../interfaces/fetch/IFetchRoute";
import { IResponse } from "../interfaces/fetch/IResponse";
import { IIdentity } from "../interfaces/IEntity";
import { FetchPostRoute } from "../requests/FetchPostRoute";
import { FetchRoute } from "../requests/FetchRoute";
import { DeletedResponse } from "../responses/DeletedResponse";
import { ErrorResponse } from "../responses/ErrorResponse";
import { IState } from "./../store/state";
import { serverMessagesAction } from "./global-actions";

@autoinject
export class FetchService {
  private state: IState;
  private router: Router;

  constructor(
    private readonly baseUrl: string,
    protected readonly http: HttpClient,
    public store: Store<IState>) {

    this.router = new Container().get(Router) as Router;

    store.state.subscribe(state => this.state = state);

    if (http) {
      http.configure(config => {
        config.withInterceptor({
          responseError(response: Response) {
            log.error("responseError", response.status);
            if (response.status === 401) {
              this.router.navigateToRoute("notAuthorized");
            }
            return response;
          }
        });
      });
    }
  }

  protected async get<T extends IIdentity>(route: FetchRoute): Promise<T | ErrorResponse> {
    if (!route.id) {
      const newEntity: Partial<T> = {};
      return newEntity as T;
    }

    const url = this.getUrl(route);

    const response = await this.http.fetch(url);

    if (response.ok) {
      const data = (await response.json()) as T;
      return data;
    } else {
      return this.GetStatusMessageError(response);
    }
  }

  protected async delete(route: FetchRoute): Promise<DeletedResponse | ErrorResponse> {
    const url = this.getUrl(route);

    const response = await this.http.fetch(url, { method: "DELETE" });

    if (response.ok) {
      const data = (await response.json()) as DeletedResponse;
      return data;
    } else {
      return this.GetStatusMessageError(response);
    }
  }

  protected async post<TRequest, TResponse extends IResponse>(model: TRequest, route: FetchPostRoute): Promise<TResponse | ErrorResponse> {
    const url = this.getUrl(route);

    const response = await this.http.fetch(url, { method: "POST", body: json(model) });

    if (response.ok) {
      const data = (await response.json()) as TResponse;
      return data;
    } else {
      return this.GetStatusMessageError(response);
    }
  }


  protected async getMany<T>(route: FetchRoute, page = 0, pageSize = 1024): Promise<T[] | ErrorResponse> {
    route.query = this.getQueryParameters(route, page, pageSize);
    const url = this.getUrl(route);

    const response = await this.http.fetch(url);

    if (response.ok) {
      const success = (await response.json()) as T[];
      return success;
    } else {
      return this.GetStatusMessageError(response);
    }
  }

  protected dispatchResponse<T>(reducer: string | Reducer<T>, response: IResponse) {
    this.store
      .pipe(reducer, response)
      .pipe(serverMessagesAction, response)
      .dispatch();
  }


  protected getQueryParameters(route: IFetchRoute, page = 0, pageSize = 1024) {
    const pageQuery = [`page=${page}`, `pageSize=${pageSize}`];

    if (route.query) {
      return Array.isArray(route.query)
        ? [...(route.query as string[]), ...pageQuery]
        : [route.query, ...pageQuery];
    } else {
      return pageQuery;
    }
  }

  protected getUrl(route: IFetchRoute) {
    const url = this.baseUrl;
    let id = "";
    let action = "";
    let queryParameters = "";

    if (!route.id && !route.action && !route.query) {
      return url;
    }

    if (route.action) {
      action += "/" + route.action;
    }

    if (route.id) {
      if (Array.isArray(route.id)) {
        id += "/" + route.id.join("/");
      } else {
        id += "/" + route.id;
      }
    }


    if (route.query) {
      if (Array.isArray(route.query)) {
        queryParameters += "?";
        queryParameters += route.query.join("&");
      } else {
        queryParameters += "?";
        queryParameters += route.query;
      }
    }

    const queryWithCulture = this.addCulture(queryParameters);

    return url + action + id + queryWithCulture;
  }

  private addCulture(query: string) {
    if (!query) {
      return `?culture=${this.state.user.culture.id}`;
    }
    if (query.includes("culture=")) {
      return query;
    }
    return `?culture=${this.state.user.culture.id}`;
  }

  // TODO check GetStatusMessageError response type
  private async GetStatusMessageError(response: Response) {
    const text = (await response.text()) as any;
    try {
      const err = JSON.parse(text) as Error;
      return new ErrorResponse(err);
    } catch (ex) {
      return new ErrorResponse(ex);
    }
  }
}

  1. I would like to test getUrl(route: IFetchRoute). I have no interest in the requests to the httpClient. My beforeEach looks like
  beforeEach(() => {
    jest.useFakeTimers();
    const initialState = new CreateState().create();
    store = new Store<IState>(initialState);
    auContainer.registerInstance(Store, store);
    auContainer.registerInstance(HttpClient, http);    
    sut = auContainer.get(MockFetchService) as MockFetchService;
    
    queryCulture = `culture=${initialState.user.culture.id}`;
  });

e.g.

  it("getUrl(): {action}/{id}", () => {
    const actual = sut.getUrl({ id: "companies-1-A", action: ACTION });
    expect(actual).toBe(`${baseUrl}/${ACTION}/companies-1-A?${queryCulture}`);
  });
  1. I would like to mock the http request.
    Question 1: I know that there is a aurelia-mock-clientbut it’s not clear how to use it?
    Question 2: How would I mock the http request using jest.mock?
    Something like (pseudo code):

    setup mockHttp = mock(http).when(http=>http.get<any>()).return({id:1, name: "test"})
    
    it("should return value of get request", done => {
    expect(sut.get(1)).toBe({id: 1, name: "test"})
    done();
    })
    
  2. I would like to test protected methods in the class. In order to test this I create a MockFetchService and override the protected methods. Is there a better of doing this without changing the visibility of the class’s methods? In C# I seem to recall that you can set some attribute on the assembly to expose properties as internal to your tests.

Of course the better way of doing this would be to move everything to do with getUrl into a seperate service with public properties and inject that into fetch-service so I wouldn’t have to worry about any dependencies when testing.

@autoinject
class MockFetchService extends FetchService {
  constructor() {
    super(baseUrl, http, store);
  }

  public getUrl = (params: IFetchRoute) => super.getUrl(params);
  public getQueryParameters = (params: IFetchRoute, page = 0, pageSize = 1024) => super.getQueryParameters(params, page, pageSize);
}
  1. I would like to test that http redirects to notAuthorized on a 401 error. In this code, it doesn’t and I still haven’t worked out why! I’m using aurelia-authentication and am 99% sure I have set up the configuration correctly.

Testing components

I don’t even know where to start. In this component I would like to test

a. selectedSalaryLevelChanged()

b. that the stateChanged(state:IState) correctly filled this.sectorAreasListetc

c. that activate(params: IParamsId) correctly sets this.selectedSalaryLevel

d. that the validationController is working correctly (mind you that’s an entirely different series of questions - how on earth do you get validation to work consistently? how do you get the error messages to show up when first loading the component etc…)

e. how to test the listItemMatcher is working - it isn’t!

<select value.bind="selectedSalaryLevel" matcher.bind="listItemMatcher">
<option repeat.for="item of salaryLevelsList">etc</option>
</select>

etc.

import { autoinject, observable } from "aurelia-framework";
import { MaterializeFormValidationRenderer } from "aurelia-materialize-bridge";
import { Router } from "aurelia-router";
import { Store } from "aurelia-store";
import { validateTrigger, ValidationController, ValidationControllerFactory, ValidationRules } from "aurelia-validation";
import { ISectorAreaListItem } from "../../interfaces/dictionaries/ISectorAreaListItem";
import { ISalaryLevelListItem } from "../../interfaces/humanResources/ISalaryLevelListItem";
import { IListItem } from "../../interfaces/IListItem";
import { IParamsId } from "../../interfaces/IParamsId";
import { Employee } from "../../models/humanResources/Employee";
import { SalaryLevelService } from "../../services/salary-level-service";
import { SectorAreaService } from "../../services/sector-area-service";
import { IState } from "../../store/state";
import { Salary } from "./../../models/humanResources/Salary";
import { MaterializeDialogDelete } from "./../../resources/elements/materialize-dialog-delete";
import { EmployeeService } from "./../../services/employee-service";

export const salaryRules = ValidationRules
  .ensure((c: Salary) => c.dateStarted).required()
  .rules;

@autoinject
export class SalaryEdit {
  public model: Employee = new Employee();
  public salary: Salary = new Salary();

  public canSave = false;

  public sectorAreasList: ISectorAreaListItem[] = [];
  public salaryLevelsList: ISalaryLevelListItem[] = [];

  @observable public selectedSectorArea: ISectorAreaListItem;
  @observable public selectedSalaryLevel: ISalaryLevelListItem;

  @observable private state: IState;

  private controller: ValidationController;
  private isNewSalary = false;
  private salaryIndex = -1;

  constructor(
    store: Store<IState>,
    factory: ValidationControllerFactory,
    private employeeService: EmployeeService,
    private sectorAreaService: SectorAreaService,
    private salaryLevelService: SalaryLevelService,
    private router: Router,
    private deleteDlg: MaterializeDialogDelete
  ) {
    store.state.subscribe(state => this.state = state);

    this.controller = factory.createForCurrentScope();
    this.controller.validateTrigger = validateTrigger.changeOrBlur;
    this.controller.addRenderer(new MaterializeFormValidationRenderer());
    this.controller.subscribe(validate => this.canSave = validate.errors.length === 0);
  }

  public get canDelete() {
    return !this.isNewSalary;
  }

  protected async stateChanged(state: IState) {
    this.model = { ...state.employee.current };

    this.sectorAreasList = state.sectorAreas.list;
    this.salaryLevelsList = state.salaryLevel.list;
  }

  protected async saveSalary() {
    await this._saveSalary();
    this.router.navigateBack();
  }

  protected selectedSectorAreaChanged(sectorArea: ISectorAreaListItem) {
    if (this.salary) {
      this.salary.assignedSectorAreaId = sectorArea.id;
    }
  }

  protected selectedSalaryLevelChanged(salaryLevel: ISalaryLevelListItem) {
    if (this.salary) {
      this.salary.salaryLevelId = salaryLevel.id;
    }
  }

  protected async activate(params: IParamsId) {
    await Promise.all([
      this.sectorAreaService.loadSectorAreaList(this.state.user.currentCompany.companyId, null),
      this.salaryLevelService.loadSalaryLevelList(this.state.user.currentCompany.companyId)
    ]);


    if (params.id === null || params.id === undefined) {
      this.isNewSalary = true;
      this.salary = new Salary();
    } else {
      this.salaryIndex = +params.id;
      this.salary = this.model.salaries[this.salaryIndex];

      this.selectedSalaryLevel = this.state.salaryLevel.list.find(c => c.id === this.salary.salaryLevelId);
      this.selectedSectorArea = this.state.sectorAreas.list.find(c => c.id === this.salary.assignedSectorAreaId);
    }

    this.controller.addObject(this.salary, salaryRules);
  }

  protected async canDeactivate() {
    const validate = await this.controller.validate();
    return validate.valid;
  }

  protected async deactivate() {
    await this._saveSalary();
  }

  protected deleteSalary() {
    this.deleteDlg.open();
  }

  protected async okDeleteSalary() {
    this.model.salaries.splice(this.salaryIndex, 1);
    await this.employeeService.saveEmployee(this.model);
    this.router.navigateBack();
  }

  protected listItemMatcher = (a: IListItem, b: IListItem) => (a && a.id) === (b && b.id);

  private async _saveSalary() {
    const validate = await this.controller.validate();
    if (!validate.valid) {
      return;
    }

    if (this.isNewSalary) {
      this.model.salaries.unshift(this.salary);
    }

    await this.employeeService.saveEmployee(this.model);
  }
}

Testing user interactions

I guess this is using protractor but I haven’t even looked at it.

It would be great to have a walkthrough of a relatively simple example - not too simple and not too complicated.

Summary

To summarize: what I would love to see is a general tutorial for testing using jest and aurelia. I understand that much of it is not specifically aurelia related, but the main difficulty I am having is understanding where and how to use jest properly.

Something else that would be useful would be tips on how to reduce the boilerplate in the beforeEach().

When writing C# I make extensive use of Autofixture so that I can set up my data in the individual tests. I find that if I hide the creation of the data in a shared method, I am always scrolling up to the top of the page to find the way that I structured the data.

I also never use mocks in C#. I use RavenDb for all of my projects, which comes with the RavenDbTestDriver which allows me to directly test the access to the db in my tests so I have no need to mock the access to the database.

Apologies for this being such a long message but I’ve tried to give you an idea of what a not very good developer has to struggle with when thinking about testing :slight_smile:

Thanks

Jeremy

4 Likes

A possible way is to use Rewire (I did not personally try it, I just read about it), you can see this article, which have the following example with Jest with a javascript private method

Private method from index.js

function someFunction(something) {
  return 'anything';
}

Jest test

const someFunction = require('./index').__get__('someFunction');
describe('someFunction', () => {
  it('should work', () => {
    expect(someFunction()).toEqual('anything');
  });
});
1 Like

Instead of going into details of your request first let’s clarify your situation.
Typically with Unit Tests you are testing behaviour not methods. This is a super important distinction because it essentially says that you should test the public contract of a class, in type-safer languages often expressed via interfaces. A simple rule, of course with exceptions, is - > Don’t test private and protected methods.

Now looking at your FetchService I see only protected/private methods. So stupid me would say, that is a class thats not meant to be consumed which clearly isn’t the point since you’re asking how to test it. So let’s first clarify who and how is using the service. Are you binding to methods from your templates? If so then I’d recommend one rule. Everything that is used externally from the class, no matter if it’s another class, a template binding or unit test, should only ever touch public fields/methods.

If you did the Service the way you have just because of creating a base to inherit from … don’t . Create an interface instead to promote the contract you want and implement it. If there is reusable logic across all children, export a function and reuse it. So essentially it boils down to preferring composition over inheritance.

If it’s hard to write unit tests, very often it’s an indicator that something is not done in a right way in your architecture, though granted this is not 100% the case. As you already proposed, if you insist on the current form, you definitely should have an extending service which implements public methods, reusing the protected from the base.


Aurelia-mock-client

Please create an issue in the docs forum. This should clearly be documented in the https://aurelia.io/docs/plugins/http-services/ docs.


With regards to testing components the question is whether you’re really writing unit tests or if this already goes more in the direction of integration tests.

a. selectedSalaryLevelChanged()
Just call the function in a unit test. You don’t need to test Aurelia. You can trust the framework to call a changed handler when things get touched.

b. stateChanged(state:IState)
Call state changed with a mocked state and expect that the outcome is what you want. You don’t need to test the internal functionality of the store plugin, it has unit tests that cover that part.

c. activate(params: IParamsId)
Create an instance of your components class and pass it your params and expect that everything is set. You do so by subscribing to selectedSalaryLevel and taking the X stream, in this case most likely 2nd since the first is initial state. Here’s an example from a test of the plugin. Again you don’t need to test that Aurelia will properly call a lifecycle event at the proper time.

d. validationController
This section most likely could use a good intro into basic unit/integration testing and should be part of the validation docs. I’m sadly not the right one to help here since I’m not actively working with it.

e. listItemMatcher
I don’t get this one. Is the code working or isn’t it? What would you like to test here?


Testing user interactions

phew … that one is worth a few books for itself. It first of all boils down to what tool you are using. Is it pure webdriver based (webdriver.io) or based on selenium (protractor)? If not is it a custom debug protocol based implementation like Cypress? Or a custom proxy solution like TestCafe?
The reason why these topics are not explained in our docs is that they have very little to do with the actual Aurelia code/framework. E2E testing is treating the SUT as a blackbox thus there is no real value we can provide. So most likely you’re better of searching for a good generic tutorial on the tool of your choice. CLI scaffolds will get you started with either Cypress or Protractor so thats might a good fit.


General example of Aurelia and Jest

this is super problematic since every testing framework/runner is different. Imagine having to go through every combination like Jest, Mocha, Jasmine, Karma, Tape, Ava, … At the time we’ve finished two of them there are 5 new variants out there or others already outdated. I don’t want to sound nonpolite but that clearly explodes the scope of Aurelia to explain how a generic test framework works plus would induce an unbearable amount of documentation efforts. If it comes to specifics like testing a component we have official docs on that to perform staged component tests. If somethings unclear there, we definitely should add more info.


To sum up, the idea of a cookbook would be to create small individual snippets that explain a specific detail. E.g how to test observables in the Store plugin, or how to mock a component with jest. What you’re asking for is simply too generic and also partially a theoretical/architectural impediment.

I’m sure this is not exactly what you looked for as an answer and certainly not answers to all your questions but lets start slow and see where we get :wink:

2 Likes

I’m sure this is not exactly what you looked for as an answer and certainly not answers to all your questions but lets start slow and see where we get

Actually that was exactly what I was looking for!

You have certainly pushed me in the right direction. Sometimes I can’t see the wood for the trees.

My FetchService - I fully recognize that it should have the getUrl and getQueryParameters in a separate injectable service with public methods, and that I shouldn’t be trying to test private/protected methods.

Testing Components - fully understood - again I think I was overthinking this

aurelia-mock-http - I’ll go over this again and see if I can get my head around it

Testing user interactions - I just asked about this because your original question suggested I come up with some questions for the “cookbook”. However, as your answer clearly states - this is up to me to go and do my own research.

General example of Aurelia and Jest - fully understand your comments - again I was just being lazy :slight_smile:

As to the cookbook - both examples you suggest would be perfect:

  • how to mock a component with jest
  • how to mock httpClient
  • how to test observables in the Store plugin

But perhaps the most important part would be your first paragraph in your answer to me.

If you are new to testing, TDD is very difficult to get your head around. When I first started using TDD I found I spent far too much time testing the wrong things. Many years ago I flew from Brazil (where I was living) to Austin, TX to do a course on TDD. I bailed out half-way through because a) I just didn’t understand it, and b) I was a newbie surrounded by professional programmers who really didn’t have any time for me. About two years later, while messing around, it suddenly “clicked”.

Anyway, thank you for taking the time to answer my questions - all of your answers are extremely helpful.

Jeremy

1 Like

I would add that I also use Jest and many times I referred to the tests that were done in the Aurelia-i18n which just happened to have been written by the great @zewa666 :wink:

A simple rule, of course with exceptions, is - > Don’t test private and protected methods.

Totally agree with that quote, I mentioned about Rewire in my previous post but I also said that I only read articles about it without necessarily using it. The biggest reason is because I had some private methods to tests (at least that is what I thought at first), then I later realized that I should test the main function and it’s behavior/expectation without accessing any of the private methods.

If it’s hard to write unit tests, very often it’s an indicator that something is not done in a right way in your architecture, though granted this is not 100% the case.

On that quote, I would say, it’s not always that simple. It took me a few weeks just to get started mostly because I was new to unit testing in Aurelia and Jest as well. But after writing a few tests, I finally started to feel more comfortable.

2 Likes

What I meant with hard is not dealing with the test frameworks API but more the general aspect of hard-to-test parts of your app. Besides that, while Jest is popular and feature-rich its far from perfect. Its extremely slow on large codebases has significant issues with Cross os testing and the TS integration is subpar.

1 Like

I just found the “cookbook” for testing!!

It’s on some Austrian guy’s website http://pragmatic-coder.net/ :smile:

10 minutes going through the articles on the first page have increased my understanding by about 1000%!

Now off to look at the tests for aurelia-i18n (which I use in every app I write)

3 Likes

> It’s on some Austrian guy’s website http://pragmatic-coder.net/ :smile:

:rofl:

3 Likes

@jwx posted a smiley (with tears) to indicate that the referenced “austrian” guy (who is not austrian, by the way) is indeed @zewa666, guy that stars prominently in this discussion.

@jeremyholt, thanks for this initiative, as it just might be that you indeed managed to get me involved in testing, more than superficially.

3 Likes