How to test a Singleton with Jest ? Comparison made between two syntaxes

31 Views Asked by At

Context

I am working on unit tests for a TS project led by Dave Gray. (YouTube, GitHub) More specificaly, I am testing the HTML display which is managed by the class ListTemplate. Because there is only one list into our application, this class is a Singleton.

The singleton defined is slightly different compared to Refactoring.Guru singleton

In the project,static instance: ListTemplate = new ListTemplate() is used while Refactoring.Guru is using

public static getInstance(): ListTemplate {
        if (!ListTemplate .instance) {
            ListTemplate .instance = new ListTemplate ();
        }

        return ListTemplate .instance;
    }

Problem

While the behavior of the application remains the same between theses two syntaxes, unit test is not. I properly configured the jsdom environment so "document" can be altered as we want.

Call stack - Syntax Singleton Refactoring Guru

"-- FullList constructor --"
"ListTemplate"
"beforeEach global"
"beforeEach local ListTemplate"
"should display html"
"-- ListTemplate constuctor --"
"<ul id="listItems"></ul>"
"HTMLUListElement {}"

Call stack - Syntax Singleton Dave Gray

"-- FullList constructor --"
"-- ListTemplate constructor --"
""
"null"
"ListTemplate"
"beforeEach global"
"beforeEach local ListTemplate"
"should display html"
TypeError: Cannot set properties of null (setting 'innerHTML')

      40 |
      41 |   clear(): void {
    > 42 |     this.ul.innerHTML = "";
         |                      ^
      43 |   }
      44 |
      45 |   debugDocAgain(): void {

      at ListTemplate.clear (src/templates/ListTemplate.ts:42:22)
      at ListTemplate.render (src/templates/ListTemplate.ts:50:10)
      at Object.<anonymous> (tests/templates/ListTemplate.test.ts:513:27)

With this syntax, the constructor of ListTemplate runs before the initialization of the dom in "beforeEach global", which is not the case with the syntax of Refactoring Guru. Why ?

Files

Please find below:

  • ListTemplate.ts
  • ListTemplate.test.ts
import FullList from "../model/FullList";

interface DOMList {
  ul: HTMLUListElement;
  clear(): void;
  render(fullList: FullList): void;
}

export default class ListTemplate implements DOMList {
  ul: HTMLUListElement;

  // Syntax 1 - Singleton - Dave Gray
  static instance: ListTemplate = new ListTemplate();

  // Syntax 2 - Singleton - Refactoring Guru
  // private static instance: ListTemplate;

  private constructor() {
    console.log("-- ListTemplate constructor --");
    // console.log(document);
    console.log(document.body.innerHTML);
    // console.log(document.body.outerHTML);
    this.ul = document.getElementById("listItems") as HTMLUListElement;
    console.log(this.ul);
  }

  // Syntax 2 - Singleton - Refactoring Guru
  // public static getInstance(): ListTemplate {
  //   if (!ListTemplate.instance) {
  //     ListTemplate.instance = new ListTemplate();
  //   }

  //   return ListTemplate.instance;
  // }

  clear(): void {
    this.ul.innerHTML = "";
  }

  render(fullList: FullList): void {
    this.clear();

    fullList.list.forEach((item) => {
      const li = document.createElement("li") as HTMLLIElement;
      li.className = "item";

      //   Checkbox
      const check = document.createElement("input") as HTMLInputElement;
      check.type = "checkbox";
      check.id = item.id;
      check.tabIndex = 0;
      check.checked = item.checked;
      li.append(check);

      check.addEventListener("change", () => {
        item.checked = !item.checked;
        fullList.save();
      });

      //   Label
      const label = document.createElement("label") as HTMLLabelElement;
      label.htmlFor = item.id;
      label.textContent = item.item;
      li.append(label);

      //   Button
      const button = document.createElement("button") as HTMLButtonElement;
      button.className = "button";
      button.textContent = "X";
      li.append(button);

      button.addEventListener("click", () => {
        fullList.removeItem(item.id);
        this.render(fullList);
      });

      this.ul.append(li);
    });
  }
}
/**
 * @jest-environment jsdom
 */

import FullList from "../../src/model/FullList";
import ListItem from "../../src/model/ListItem";
import ListTemplate from "../../src/templates/ListTemplate";
import { getAllByRole, getByText, waitFor } from "@testing-library/dom";

beforeEach(() => {
  console.log("beforeEach global");

  document.body.innerHTML = `<ul id="listItems"></ul>`;
});

describe("ListTemplate", () => {
  console.log("ListTemplate");
  beforeEach(() => {
    console.log("beforeEach local ListTemplate");
    // Doesn't change anything
    // document.body.innerHTML = `<ul id="listItems"></ul>`;
  });
  test("should display html", async () => {
    console.log("should display html");

    // Create items
    const item1 = new ListItem("1", "item1", true);
    const item2 = new ListItem("2", "item2", true);

    // Mocking the getter list of FullList to return mocked data
    jest
      .spyOn(FullList.prototype, "list", "get")
      .mockReturnValue([item1, item2]);

    // Syntax 1 - Singleton - Dave Gray - NOK - Reason ?
    ListTemplate.instance.render(FullList.instance);

    // Syntax 2 - Singleton - Refactoring Guru - OK
    // const singletonInstance = ListTemplate.getInstance();
    // singletonInstance.render(FullList.instance);

    let ulElement: HTMLElement = document.getElementById(
      "listItems"
    ) as HTMLElement;

    expect(ulElement).toBeInTheDocument();

    await waitFor(() => {
      // Li
      const li = getAllByRole(ulElement, "listitem");
      // console.log(li);
      expect(li).toHaveLength(2);

      // Checkbox
      const checkbox = getAllByRole(ulElement, "checkbox");
      // console.log(checkbox);
      expect(checkbox).toHaveLength(2);
      checkbox.forEach((item) => {
        expect(item).toBeChecked();
      });

      // Labels
      expect(getByText(ulElement, "item1")).toBeInTheDocument();
      expect(getByText(ulElement, "item2")).toBeInTheDocument();

      // Buttons
      const buttons = getAllByRole(ulElement, "button");
      // console.log(buttons);
      expect(buttons).toHaveLength(2);
    });
  });
});

Configuration

"devDependencies": {
    "@babel/core": "^7.23.9",
    "@babel/preset-env": "^7.23.9",
    "@babel/preset-typescript": "^7.23.3",
    "@testing-library/dom": "^9.3.4",
    "@testing-library/jest-dom": "^6.4.2",
    "@types/jest": "^29.5.12",
    "babel-jest": "^29.7.0",
    "jest": "^29.7.0",
    "jest-environment-jsdom": "^29.7.0",
    "jsdom": "^24.0.0",
    "ts-node": "^10.9.2",
    "typescript": "^5.2.2",
    "vite": "^5.1.4"
  }

I thought this was related to the Setup and Teardown of Jest. However, with another related subject, after some tests, position of beforeEach doesn't change anything.

Expectations

The test should run without problem regardless of the syntax used.

I hope I'm clear on the context, the problem and the expectations. If not, I would be happy to edit the message and give you more information.

Thanks in advance for your time and your expertise on the subject.

0

There are 0 best solutions below