A proposition for relevant tests in your front-end application

Aude Planchamp
ekino-france
Published in
16 min readJun 21, 2023

--

Preamble: As you can see in the title, this article does not claim that the following strategy IS the good one. Coding is very subjective and as of now, I don’t see a consensus among developers on how to implement useful and relevant tests on a front-end application.

This article is merely a proposition on how to implement tests, I have been working with tests for 6 years now with different tools and my point of view has evolved over the years. Only recently have I found a way to write a test that I’m confident will prevent regression.

When to use unit tests? When to use integration test? How should I test? What should I mock? What is the difference between a unit test and an integration test anyway?

In this article, I will try to provide answers to all those questions.

Expectations

My goal when I implement tests is to make sure that we implement the most relevant tests and thus have faith in our SonarQube coverage as a good indicator to prevent regressions and ease new developers’ onboarding on the project.

NB: your code coverage alone does not mean that your application is well tested, it depends on how it is configured and it cannot tell if your tests are useful or not.

Source: Demystifying the software engineering test pyramid

You are probably already acquainted with the test pyramid above. However, in this article, we are not going to talk about end-to-end tests. Let’s imagine that you (unfortunately) don’t have the budget for implementing them.

We will focus on unit and integration tests within the code directly. This means that those tests cannot be connected to real APIs, they must be mocked as well as all other third parties.

Our expectations regarding tests are that they should :

  • help developers during development - as they will think about the different test cases that they should write and that will help them implement a more robust code
  • provide documentation regarding management rules
  • prevent regressions

Unit tests

They are tests that are usually used on very small portions of code, the entrance parameters are mocked and the same portion of code is tested with variations of those parameters to ensure that every use case is covered. They are here to test the core logic of your implementation.

A unit can be almost anything you want it to be — a line of code, a method, or a class. Generally though, smaller is better. Smaller tests give you a much more granular view of how your code is performing and most of all, they are precise. When it fails, it is very easy to find out why.

Integration tests

Some tests are generally more comprehensive than others and will test several modules at the same time. They are less precise, but they ensure that these modules all work together and act as your best friends to prevent regressions.

In my opinion, a project well tested uses both unit tests and integration tests.

Otherwise, you could end up with the kind of issues in the picture below 😅

Unit tests without integration tests, the cloud stack company

The hard part is to identify the best tool (type of test) to use for the code you are currently writing.

How should you test?

The best advice I can give is that it is very important to prevent testing implementation details: that means, testing the actually expected behavior and not the way the code is implemented.

You need to have tests that you can use and trust when refactoring a portion of code, compared to a test that you will have to re-implement completely when refactoring.

When you are unit testing a small portion of code (a function that is a util for instance), you must make sure to set up mocked parameters to test all use cases (the number of test cases will depend on the conditions within your code).

If you are using typescript (and I strongly suggest you do), the mocks you will use must all be typed, indeed, typing your mocks will make sure that whenever the model updates, you will update your mocks and all your tests will be run with this new model: this way you are making sure that updating the model did not break anything.
Small tip: it is very useful and time-saving to create a directory with typed mocks to reuse them for future tests.

Implementing unit tests on small portions of the code will make sure that the core logic of your code runs well and enable you to go to the next level: integration testing.

The center of our front-end applications is components because they constitute what is used by your users. If your components are well tested, then there is a good chance your whole application is well tested.

When testing your components, for me the best way is to use integration tests.

Integration testing with components

Source : javatpoint

When you are testing a component, you must test the component the same way it is used by end users. That means:

  • triggering actions through HTML rendered
  • checking that actions have been effective through updates in the HTML rendered and/or expecting certain external param (functions) to have been called and/or side effects to have occurred

Before testing a component, use it within the platform and build your test cases based on how you are interacting with it. Some test cases can be full scenarios.

The idea is to trigger component actions through HTML and not call properties from components directly. Those properties must be called as a consequence of your interaction with the HTML. Then you will make sure that the code within them was properly executed because the consequence of your action worked as expected: an update in the DOM and/or a side effect triggered.

Always ask yourself, if I change the name of my function or my property in my component, will my test still work? If the answer is yes, then you are on the right track.

1) Example of counter incrementation (react)

export function Counter() {
const [count, setCount] = React.useState(0);
const increment = () => {
setCount(count + 1);
};
return (
<div>
<div>{count}</div>
<button onClick={increment}>click me</button>
</div>
);
}
import "@testing-library/jest-dom/extend-expect";
import userEvent from "@testing-library/user-event";
import { render, screen } from "@testing-library/react";

test("renders counter", async () => {
render(<Counter />);
const count = screen.getByText("0");
const button = screen.getByText(/click me/i);
userEvent.click(button);
expect(count).toHaveTextContent("1");
});

In the above example, if you rename in the Counter component the function “increment”, the test will still pass. Indeed, only the DOM is tested: “increment is just a means to an end, it should not be executed manually from your test.

If you use testing-library like in this example, this tool will not allow you to call directly the “increment” function anyway (that’s why I recommend this tool). But if you are using tools like enzyme, you could access all internal logics within the components (especially if your component is a complex class, it is tempting), that is where you need to be very rigorous and make sure you still test the components through the DOM.

2) Example with routing and tracking side effects (angular)

@Component({
selector: 'app-landing-ration',
templateUrl: './landing-ration.component.html',
styleUrls: ['./landing-ration.component.scss'],
template: `
<div class="pf-landing-ration">
<img class="pf-landing-ration-illustration-image" [src]="illustrationPath" alt="'" />
<rc-button id="submit-portion-guide" (clicked)="redirectToPortionGuide()" name="Redirect to portion"> </rc-button>
</div>
`,
})
export class LandingRationComponent {
public readonly illustrationPath = `${environment.blob_url}/images/illustration_ration.svg`;
constructor(private router: Router, private trackingService: TrackingService) {}

public redirectToPortionGuide() {
this.router.navigate(['/portion-guide']);
this.trackingService.trackingDPTCallback('See our Portion');
}
}
describe('LandingRationComponent', () => {
let fixture: ComponentFixture<LandingRationComponent>;
let element: DebugElement;
let router: Router;
beforeEach(
waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [LandingRationComponent],
imports: [RouterTestingModule, RCButtonModule],
providers: [TrackingService, { provide: Router, useValue: { navigate: jest.fn() } }],
}).compileComponents();
})
);

beforeEach(() => {
fixture = TestBed.createComponent(LandingRationComponent);
element = fixture.debugElement;
router = TestBed.inject(Router);
});

it('should redirect to portion guide and send analytic event', () => {
jest.spyOn(router, 'navigate');
jest.spyOn(window.dataLayer as any, 'push');
fixture.detectChanges();
element.query(By.css('#submit-portion-guide button')).nativeElement.click();
fixture.detectChanges();
expect(router.navigate).toHaveBeenCalledWith(['/portion-guide']);
expect(window.dataLayer.push).toHaveBeenCalledWith({
event: 'dailyPortionButtonClick',
dailyPortionButtonClick: {
name: 'See our Portion',
},
});
});
});

A few things should be noticed in the above example :

  • The TrackingService is not mocked, the datalayer is directly tested, which means that the code from the TrackingService that calls the datalayer in the end is also tested here. And to me, it is tested in the best way because this is how it happens in reality.
  • The Router navigate function is mocked because here we want to focus on this component for the test, not the components where the user will be redirected (but it is interesting to implement advanced test scenarios including redirections between multiple components in other contexts).
  • Here, you could rename the function “redirectToPortionGuide” or “trackingDPTCallback” or even rework the code within them and your test will still pass: you can trust your test if you decide to refactor your code.

If some code within your component cannot be tested by interacting with the DOM, ask yourself: does this code should really be in my component?

In practice, implementing this strategy can be very difficult if you use unit test: indeed, if you unit test the component, it means that you are mocking most of the stuff that is used by your component, and maybe some of the real stuff is actually needed to see the final result reflected in the HTML.

To sum up, when integration testing components, mock as less stuff as you can.

Source : giphy

Of course, there are some things that you have to mock: the API results, and the initial context of your component.

When you are interacting with your component, it will be in a particular context depending on all the use cases. This context will depend on the data he is getting (through props for react, inputs for angular, from a global state, from the result of an API, from a browser storage…).

You must try to reproduce the context within your tests, and that is done by using mocks that are as close to reality as possible. So the mocks you will provide to your components must be retrieved from real use cases.

BUT, when I am saying that you have to mock the initial context here, I am not saying that you should mock the system that provides the data: I am saying that you should try to mock only the initial data and still use the real system: for instance, if you are using a global state management, don’t mock the state management, mock only the initial state of the system, keep using the full engine so that it will be tested also.

Source : giphy

Let me explain in detail what I mean: In most of our front-end applications we need to manage some global shared state, and many components are connected to this state.

Let’s say your component is connected to a portion of this global state :

  • the user clicks on a button
  • it calls a function of the state management that will update the state
  • the component is connected to this updated state and thus changes its rendered DOM

You can imagine the counter-example above but that would be handled by a global state and not only the internal component state

If you mock the state management entirely, your test will not be able to run all the chain, the global state will not be updated upon the button click and you will not see the update in the DOM.

On the contrary, if you only mock the initial global state but still use the real state management, the state will be updated by the system upon the button click and you will be able to expect the DOM changes: and in bonus, you just tested a lot of code with very little effort: triggering click, expecting that the UI has updated and that’s it.

A lot of solutions exist in terms of state management, let me try to be more specific regarding this example with a well-known state management system: redux (or ngrx for angular).

What I am trying to say here is :

  • don’t unit test reducers, actions, and selectors on their own, for me it is a waste of time (if the reducers/selectors implement some complicated logic, create util functions and unit test them but don’t test the reducer itself: it is like testing redux)
  • all those parts can be tested through your component: test your component connected to the real redux store and only provide a mocked initial state. The actions/reducers/selectors will be tested when you interact with the DOM because interacting with the DOM will trigger the execution of their code.

Let’s dig into a concrete example with Angular and NGRX (which is redux with observables)

If you remember what I said above, the way you write your tests should reflect the way users interact with the interface :

it('full scenario with search, product selection and submit', async () => {
// specific to angular
fixture.detectChanges();

// API IS CALLED ON INIT TO FETCH ALL PRODUCTS
expect(productService.fetchProducts).toHaveBeenCalled();
// PRODUCTS ARE DISPLAYED, IN THE BACKGROUND, A LOT OF CODE IS TESTED (NGRX effects, reducers, selectors)
expect(element.queryAll(By.css('.product-card')).length).toEqual(8);
expect(element.queryAll(By.css('.product-name')).map((item) => item.nativeElement.textContent)).toEqual([
'Product puppy',
'Product diet',
'Product urinary',
'Product with chicken',
'Product calm',
'Product senior',
'Product hypoallergenic',
'Product renal',
]);

// TYPE SEARCH
element.query(By.css('#filters-search')).nativeElement.value = 'hypo';
element.query(By.css('#filters-search')).nativeElement.value.dispatchEvent(new Event('input'));
fixture.detectChanges();

// SUBMIT SEARCH
element.query(By.css('#submit-filters')).nativeElement.click();
fixture.detectChanges();

// API IS NOT CALLED AGAIN, ONLY FRONT END FILTERS ARE APPLIED ON EXISTING LIST
expect(productService.fetchProducts).not.toHaveBeenCalled();
// ONLY ONE PRODUCT IS NOW DISPLAYED
expect(element.queryAll(By.css('.product-card')).length).toEqual(1);
expect(element.query(By.css('.product-name'))).toEqual('Product hypoallergenic');

// DRY PRODUCT 'Product hypoallergenic' IS SELECTED
element.query(By.css('#select-product-360095')).triggerEventHandler('click', null);
fixture.detectChanges();

// CLICK ON "CONTINUE" BUTTON
element.query(By.css('#productFooterContinueAction')).triggerEventHandler('click', null);
fixture.detectChanges();

// USER IS REDIRECTED
expect(router.navigate).toHaveBeenCalledWith(['/daily-allowance/patient']);
});

The steps in the test are the exact reflection of what you see in the video, the component is tested through the DOM.

In order to understand what is tested here, I need to explain how the component works and uses the NGRX store.

STEP 1 : When the component loads, an API call to fetch all products is triggered through an effect (an ngrx middleware to handle side effects and then update the state through actions). Some technical filters apply and the state is updated through an action :

// FROM COMPONENT : CALL FAÇADE
this.productsFacade.getAllProducts({}, false);

// FROM FACADE : DISPATCH ACTION TO TRIGGER EFFECT getAllProductsEffect$
getAllProducts(filterParams: FetchProductDataFilters, openFilledCategories?: boolean): void {
this.store$.dispatch(getAllProducts({ filterParams, openFilledCategories }));
}
// FROM EFFECT getAllProductsEffect$
// 1) TRIGGER API CALL
// 2) ACTION getAllProductsSuccess TO UPDATE STATE THROUGH REDUCER
// 3) ACTION setFilteredProducts TO TRIGGER ANOTHER EFFECT
getAllProductsEffect$ = createEffect(() =>
this.actions$.pipe(
ofType(getAllProducts),
switchMap((action) => {
return this.productService.fetchProducts().pipe(
// filterCatalogProducts apply filters that I wish were on the backend side
// BTW, this util function should also be unit tested with all use cases because it contains some core logic
map((products) => [filterCatalogProducts(products, action.filterParams), action.filterParams]),
switchMap(([products]) => [
getAllProductsSuccess({ allProductList: products }),
setFilteredProducts({})
]),
catchError(() => [getAllProductsFail()])
);
})
)
);

The effect triggers two actions :

  • getAllProductsSuccess to update the current state with all products retrieved from API without having user search filters applied. The value of allProducts will never be updated after, it is the raw result of the API without front-end filters applied.
// FROM REDUCER : DESCRIBE HOW getAllProductsSuccess ACTION UPDATES THE STATE
on(getAllProductsSuccess, (state, { allProductList }) => ({
...state,
allProducts,
productsLoading: false,
})),
  • setFilteredProducts to trigger another effect to apply user search filters : by default in the user search the specie filter is applied (dog products only) and products are also organized by categories
// FROM EFFECT setFilteredProductsEffect$
// 1) FETCH CURRENT STATE VALUES THROUGH SELECTORS
// 2) FILTER PRODUCTS BASED ON CURRENT SEARCH
// 3) ORGANIZE PRODUCTS BY CATEGORIES
// 4) DISPATCH setFilteredProductsSuccess ACTION TO UPDATE STATE
setFilteredProductsEffect$ = createEffect(() =>
this.actions$.pipe(
ofType(setFilteredProducts),
withLatestFrom(
// GET THE DEFAULT USER SEARCH FILTER VALUES FROM STATE
// filtersValues: {
// pillar: ProductPillar.VET,
// specie: SpeciesCode.Dog,
// search: '',
// dry: false,
// wet: false,
// birthAndGrowth: false,
// mature: false,
// adult: false,
// size: BreedSize.All,
// }
this.store$.select(selectFiltersValues),
// GET THE allProducts FROM STATE THAT WAS JUST FILLED WITH THE PREVIOUS EFFECT
this.store$.select(selectAllProducts),
),
map(([_, filters, allProducts, selectedProducts]) => [
// filter products depending on current user search (filtersValues)
filterProductsByCatalogFilters(allProducts, filters),
filters,
]),
// format product list to have them organized by categories instead of a simple array
map(([products, filters]) => [formatProductsByCategory(products, filters.pillar), products.length]),
map(([products, filteredProductLength]) => setFilteredProductsSuccess({ filteredProductList: products, filteredProductLength }))
)
);

The effect dispaches the setFilteredProductsSuccess action to update the current state :

// FROM REDUCER : DESCRIBE HOW setFilteredProductsSuccess ACTION UPDATES THE STATE
on(setFilteredProductsSuccess, (state, { filteredProducts, filteredProductsLength }) => ({
...state,
filteredProducts,
filteredProductsLength,
})),

The component is connected to the state through a façade and uses both filteredProducts and filteredProductsLength :

// FROM FACADE : USE SELECTORS
public filteredProductsLength$ = this.store$.select(selectFilteredProductsLength);
// const selectFilteredProducts = createSelector(productsState, (state) => state.filteredProducts);
public filteredProducts$ = this.store$.select(selectFilteredProducts);
// const selectFilteredProductsLength = createSelector(productsState, (state) => state.filteredProductsLength);

// FROM COMPONENT : CONNECT TO STATE USING FAÇADE
public filteredProducts$ = this.productsFacade.filteredProducts$;
public filteredProductsLength$ = this.productsFacade.filteredProductsLength$;

After all this logic is applied, the interface displays the 8 products that you see in the video. Initially, there were 14 products returned by the API (because I mocked 14 results from API on purpose to test all filters).

STEP 2 : When the user enters his search and submits :

// FROM COMPONENT : CALL FAÇADE
submit(): void {
if (this.form.valid) {
const values = this.form.getRawValue();
// the values contain the search with name "hypo"
this.productsFacade.setProductFilters(values);
this.productsFacade.setFilteredProducts();
}
}
  • first the action setProductFilters is dispatched
// FROM FACADE : DISPATCH ACTION setProductFilters TO UPDATE CURRENT FILTERS IN STATE
setProductFilters(filtersValues: ProductCatalogFilterValues): void {
this.store$.dispatch(setProductFilters({ filtersValues }));
}

// FROM REDUCER : DESCRIBE HOW setProductFilters ACTION UPDATES THE STATE
on(setProductFilters, (state, { filtersValues }) => ({
...state,
filtersValues,
})),

// THE FILTER VALUES IN THE STATE ARE NOW :
filtersValues: {
pillar: ProductPillar.VET,
specie: SpeciesCode.Dog,
search: 'hypo', // THE DIFFERENCE WITH THE DEFAULT STATE IS HERE
dry: false,
wet: false,
birthAndGrowth: false,
mature: false,
adult: false,
size: BreedSize.All,
}
  • then the action setFilteredProducts is dispatched, it triggers the same effect as above, but this time the current user search is different
// FROM EFFECT setFilteredProductsEffect$ (again)
// 1) FETCHES CURRENT STATE VALUES THROUGH SELECTORS
// 2) FILTER PRODUCTS BASED ON CURRENT SEARCH that now has "search: 'hypo'"
// 3) ORGANIZE FILTERS BY CATEGORIES
// 4) DISPATCH setFilteredProductsSuccess ACTION TO UPDATE STATE
setFilteredProductsEffect$ = createEffect(() =>
this.actions$.pipe(
ofType(setFilteredProducts),
withLatestFrom(
this.store$.select(selectFiltersValues),
this.store$.select(selectAllProducts),
),
map(([_, filters, allProducts, selectedProducts]) => [
// filter products that have a match with "hypo" in their name
filterProductsByCatalogFilters(allProducts, filters),
filters,
]),
// format product list to have them organized by categories instead of a simple array
map(([products, filters]) => [formatProductsByCategory(products, filters.pillar), products.length]),
map(([products, filteredProductLength]) => setFilteredProductsSuccess({ filteredProductList: products, filteredProductLength }))
)
);

After all this logic is applied, the interface displays the only product that you see in the video. The API was not called again, the filters applied are purely front-end filters.

STEP 3 : When the user selects the product :

// FROM COMPONENT : CALL FAÇADE
selectProduct(product: Product, packId?: string): void {
this.productsFacade.setSelectedProduct({ product, packId });
}

// FROM FACADE : DISPATCH ACTION setSelectedProduct TO UPDATE STATE
setSelectedProduct({ product, packId }: { product: Product; packId?: string }, allowMultipleSelection = false): void {
this.store$.dispatch(setSelectedProduct({ selectedProduct: { product, packId }, allowMultipleSelection }));
}

// FROM REDUCER : DESCRIBE HOW setSelectedProduct ACTION UPDATES THE STATE
on(setSelectedProducts, (state, { products }) => ({
...state,
selectedProducts: products.map((product) => ({ product: product, packId: product?.packages[0]?.sCode })),
})),

The component is connected to the state through a façade and uses selectedProducts to know that it should display the footer :

// FROM FACADE : USE SELECTORS
public selectedProducts$ = this.store$.select(selectSelectedProducts);
// export const selectSelectedProducts = createSelector(productsState, (state: ProductsState) =>
// state?.selectedProducts?.map((selectedProduct) => selectedProduct?.product)
// );

// FROM COMPONENT : CONNECT TO STATE USING FAÇADE
public selectedProducts$ = this.productsFacade.selectedProducts$;

Because the footer is displayed, the continue button is now accessible.

STEP 4 : When the user submits :

this.router.navigate(['daily-allowance/patient']);

If you test this entire component connected to the real store, ALL the above logic is tested : the effects, the actions, the selectors, the reducers, the façade and with only 20 lines of code in your test.

In order to do that it is quite simple : don’t mock the store with something like “provideMockStore”, simply setup your component in your test the way it is setup in the real code :

// SAME SETUP THAN IN THE "REAL" CODE TO CONNECT TO STORE
imports: [
// [...]
// ProductsEffects contains the effects detailed above
EffectsModule.forRoot([ProductsEffects]),
StoreModule.forRoot({
// productsReducer is the one detailed above
products: productsReducer
}),
// [...]
],
declarations: [ProductsPageComponent],
providers: [
// [...]
// ProductsFacade is the one detailed above
ProductsFacade,
// in the setup, only the API result is mocked
{
provide: ProductService,
useValue: {
// allProducts mock contains 14 products
fetchProducts: jest.fn(() => of(allProducts)),
},
},
// [...]
],

A lot of projects using redux/NGRX will actually not be able to test this scenario because most of the time the store is mocked (only a fixed state is provided, the actions/reducers/selectors are mocked). Because of that, when an action is dispatched, nothing happens. This means they have to write many tests with different initial states to test all the use cases, rather than having a scenario that most closely resembles reality.

With this strategy, you could actually decide to replace the state management system with something else and this would require very few changes in the test implementation: again, you can trust your test for refactoring!

NB: just to give my point of view regarding jest snapshots, they are useless IF developers keep running tests with -u (which updates snapshots automatically). Like everything, it is a tool, and a tool is useful if it is well used. I would still recommend using them because they are cheap (except if you use them too much your tests will be slow) and they provide a very quick impression of the DOM which is what we want to test in the end. They are also very practical for debugging (to understand the state your component is in).

Conclusion

To sum up, my bits of advice for testing front-end applications are :

  • use a mix of unit and integration tests
  • don’t run after code coverage, run after tests that you can trust when you refactor your code
  • make sure the data mocks are typed and as close to reality as possible
  • use integration tests for components and mock connected services/modules/functions as little as possible, if you use a state management, don’t mock the system, test it with the component
  • test your components the same way they will be used by your end users

I hope this proposal will at least give you some food for thought.

Bisous.

About ekino

The ekino group has been supporting major groups and start-ups in their transformation for over 10 years, helping them to imagine and implement their digital services and deploying new methodologies within their project teams. A pioneer in its holistic approach, ekino draws on the synergy of its expertise to build coherent, long-term solutions.

To find out more, visit our website — ekino.com.

--

--