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);
}
}
}
- I would like to test
getUrl(route: IFetchRoute)
. I have no interest in the requests to thehttpClient
. MybeforeEach
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}`);
});
-
I would like to mock the http request.
Question 1: I know that there is aaurelia-mock-client
but it’s not clear how to use it?
Question 2: How would I mock the http request usingjest.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(); })
-
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);
}
- 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 usingaurelia-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.sectorAreasList
etc
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
Thanks
Jeremy