Depending on the Context — Dependency Injecting Mocks Using @lit/context

Published on March 29, 2024

Depending on the Context — Dependency Injecting Mocks Using @lit/context

Photo by Mulyadi on Unsplash

Dependency injection is one of the implementation techniques of Inversion of Control (IoC), allowing to communicate through contracts, thus making it easier to switch between different implementations (decoupling).

In the context of testing and isolation, dependency injection is useful for injecting mocks to components during Unit/Component tests.

While Angular has built in dependency injection, React and Lit leave it to the developer to choose the dependency injection method. Let’s explore one of those options: using context.

Let’s Render Some Pokémons

pokemon-component

Consider the following Lit component, rendering pokémons:

import { PokemonService } from "@services/pokemon.service";
import { LitElement, html } from "lit";
import { customElement, state } from "lit/decorators.js";

@customElement("pokemon-component")
export class PokemonCatalog extends LitElement {
@state()
pokemon!: {
next: string | null;
previous: string | null;
results: { name: string }[];
};

@state()
pokemonIndex: number = 1;

private pokemonService = new PokemonService();

override connectedCallback() {
super.connectedCallback();
this.loadPokemon();
}

loadPokemon = async () => (this.pokemon = await this.pokemonService.getPokemonByOffset());

loadNext = async () => {
this.pokemon = await this.pokemonService.getPokemon(this.pokemon.next);
this.pokemonIndex += 1;
};

loadPrev = async () => {
this.pokemon = await this.pokemonService.getPokemon(this.pokemon.previous);
this.pokemonIndex -= 1;
};

protected override render() {
return !this.pokemon
? ""
: html`
data-hook="pokemon-image"
src="https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/${this.pokemonIndex}.png"
/>

${this.pokemon.results[0].name}



Prev
Next

`;
}
}

This component is using a service to fecth pokemon data. The service’s implementation is pretty straight forward:

export class PokemonService {
private baseUrl = "https://pokeapi.co/api/v2/pokemon";
getPokemon = async (url: string | URL) => await (await fetch(url)).json();

getPokemonByOffset = async (offset: string = "0") => {
const params = new URLSearchParams({ limit: "1", offset });
const fetchUrl = new URL(this.baseUrl);
fetchUrl.search = params.toString();
return this.getPokemon(fetchUrl);
};
}

BUT, the component is creating an instance of PokemonService internally. therefore, we cannot test this component in isolation as we cannot pass a mock service during the test.

Property Level Injection

We can easily change the design so that the Pokémon component will get the service as an input property, like so:

 @property({ attribute: false })
private pokemonService?: PokemonService;

We will have to pass the service from our main compoent, PokemonApp, which resides in main.ts:

@customElement("pokemon-app")
export class PokemonApp extends LitElement {

pokemonService: PokemonService = new PokemonService();

protected override render() {
return html`

.pokemonService="${this.pokemonService}"
>
`;
}
}

Testing in Isolation

Now, we can pass a mock service to the component during the test.

Test Driver

First we will create a test driver, following the driver pattern. We will be using Cypresshelper and CypressLitComponentHelper. We will create a mock PokemonService, using CypressHelper, which uses ts-stubber (a generic stub creator) to create a stubbed instance of the service.

import { CypressHelper } from "@shellygo/cypress-test-utils";
import { CypressLitComponentHelper } from "@shellygo/cypress-test-utils/lit";
import { html } from "lit";
import { PokemonList, PokemonService } from "../../services/pokemon.service";
import { Pokemon } from "./pokemon.component";

export class PokemonComponentDriver {
private helper = new CypressHelper({ defaultDataAttribute: "data-hook" });
private componentHelper = new CypressLitComponentHelper();

private pokemonServiceMock = this.helper.given.stubbedInstance(PokemonService);

beforeAndAfter = () => {
this.helper.beforeAndAfter();
};

given = {
pokemon: (value: { next: string | null; previous: string | null; results: { name: string }[] }) => {
this.pokemonServiceMock.getPokemon.returns(value);
this.pokemonServiceMock.getPokemonByOffset.returns(value);
}
};

when = {
render: (element: Pokemon) => {
this.componentHelper.when.unmount(element);
this.componentHelper.when.mount(
element,
html` `
);
},
clickNext: () => this.helper.when.click("next"),
clickPrev: () => this.helper.when.click("prev")
};

get = {
nameText: () => this.helper.get.elementsText("pokemon-name"),
mock: { pokemonService: () => this.pokemonServiceMock }
};
}

Adding a Test

describe("PokemonCatalogComponent Tests", () => {
const chance = new Chance();
const { when, given, get, beforeAndAfter } = new PokemonComponentDriver();

beforeAndAfter();

describe("given pokemon data", () => {
const name = chance.word();
const url = chance.url();

const pokemon = {
results: [{ name, url }],
next: url,
previous: url
};

beforeEach(() => {
given.pokemon(pokemon);
when.render(new Pokemon());
});

it("should render pokemon name", () => {
then(get.nameText()).shouldEqual(name);
});
});
});

Running the Test

when trying to run test, we get the following error: currentDirective._$initialize is not a function.

Failing test

What is Happening Here?

Apparently lit adds method when we pass a property object, and as we pass a mock object, those methods don’t exist. One way to overcome this, is pass a method for getting the service, instead of the service itself. This will require some changes in the implementation of pokemon-component:

@property({ attribute: false })
private getPokemonService?: () => PokemonService;

private pokemonService: PokemonService;
override connectedCallback() {
super.connectedCallback();
this.pokemonService = this.getPokemonService();
this.loadPokemon();
}

As well as some changes in the main PokeonApp:

@customElement("pokemon-app")
export class PokemonApp extends LitElement {

pokemonService: PokemonService = new PokemonService();

protected override render() {
return html`

.getPokemonService="${() => this.pokemonService}"
>
`;
}
}

And in the test driver, we have to pass the mock service when rendering the component:

 when = {
render: (element: Pokemon) => {
this.componentHelper.when.unmount(element);
this.componentHelper.when.mount(
element,
html``
);
},
clickNext: () => this.helper.when.click("next"),
clickPrev: () => this.helper.when.click("prev")
};

But now, our test will pass:

Passing Test

Let’s Add a Context

Consider an application where multiple components are depending on the same service (like a logger). Instead of injecting the service to each component individually, we can use a context.

@lit/context is very similar to React’s Context, or to dependency injection systems like Angular’s. Using context involves a context object, a provider and a consumer, which communicate using the context.

Context Definition

for the implementation of the provider, we will use createContext from @lit/context:

import { createContext } from "@lit/context";

export const pokemonServiceContext = createContext("__pokemon_context__");

Context Provider

We will create the context provider in our PokemonApp component.

import { provide } from "@lit/context";

@customElement("pokemon-app")
export class PokemonApp extends LitElement {
@provide({ context: pokemonServiceContext })
pokemonService: PokemonService = new PokemonService();

protected override render() {
return html`


`;
}
}

Context Consumer

In pokemon-component we will consume the service from the context provider, like so:


@consume({ context: pokemonServiceContext })
@property({ attribute: false })
private pokemonService?: PokemonService;

Injecting a Mock Service

Now all we have to do is inject the mock service during the test

import { provide } from "@lit/context";

const pokemonServiceMock = new CypressHelper().given.stubbedInstance(PokemonService);

@customElement("pokemon-service-provider")
export class PokemonServiceProvider extends LitElement {
@provide({ context: pokemonServiceContext })
pokemonService: PokemonService = pokemonServiceMock;
protected override render() {
return html``;
}
}
.
.
.
when = {
render: (element: Pokemon) => {
this.componentHelper.when.unmount(element);
this.componentHelper.when.mount(
element,
html``
);
},
clickNext: () => this.helper.when.click("next"),
clickPrev: () => this.helper.when.click("prev")
};

Running the Test

the test will pass, and we can see the getPokemonByOffset stub is being called during the test.

Summary

We explored different ways to inject dependencies to a lit component, the first of which was property level injection, where we stumbled upon a caveat when passing a mock object as an object property and found a way to overcome it. We then experimented with using @lit/context for dependency injection and injected a mock service during the test.

Happy Testing!