Test the composable

In this sixth episode, we face a common challenge when testing composables that use `onMounted`. Should the lifecycle hook live in the composable or in the component? We explore multiple ways to test it and the trade-offs of each approach.

8 min read
Share
Test the composable

First Test (v4-1)

👉 Code: use-version-4-1.unit.spec.ts

// File: layers/version-04/__tests__/use-version.spec.ts
// @vitest-environment nuxt
import { describe, it, expect } from "vitest";
import { useVersion } from "../composables/useVersion";
import pkg from "@@/package.json";

describe("useVersion", () => {
  // Warning: "[Vue warn]: onMounted is called when there is no active component instance to be associated with"
  it("should return the correct initial state", () => {
    const { version, isVisible } = useVersion();
    expect(version).toBe(pkg.version); // pkg.version = "0.0.2"
    expect(isVisible.value).toBe(false);
  });
});

There are two issues (“code smells”) with this test:

Let’s focus on the second issue. We have two options:

  1. Move onMounted to the wrapper component.
  2. Create a Vue app to test the composable.

When Should onMounted Be Used Inside a Composable?

Option 2 might seem heavy-handed and could indicate an area for improvement. However, if the logic is shared across multiple components, placing onMounted inside the composable may be acceptable. Still, because it complicates testing, it might be better to move onMounted outside the composable when possible.

To explore testing a composable that uses onMounted, we can use a helper function called withSetup, taken from Alexander Opalic. This function creates a Vue app with a setup context to handle lifecycle methods and expose the composable.

Testing with withSetup (v4-2)

👉 Code: use-version-4-2.unit.spec.ts

// @vitest-environment nuxt
import { describe, it, expect } from "vitest";
import { useVersion } from "../composables/useVersion";
import pkg from "@@/package.json";
import type { App } from "vue";
import { createApp } from "vue";

// Utility function to create a Vue component context with a setup function.
// Source: https://alexop.dev/posts/how-to-test-vue-composables/#introduction-to-withsetup
function withSetup<T>(composable: () => T): [T, App] {
  let result: T | null = null;
  const app = createApp({
    setup() {
      result = composable();
      return () => {}; // Render an empty element
    },
  });
  app.mount(document.createElement("div")); // Attach to the DOM
  return [result as T, app]; // The type assertion is safe since result will be set.
}

describe("useVersion", () => {
  it("should return the correct initial state", () => {
    const [result] = withSetup(() => useVersion());
    expect(result.version).toBe(pkg.version); // Expected "0.0.2"
    expect(result.isVisible.value).toBe(false);
  });
});

However, we encounter another issue: although the warning disappears, the test now fails. The problem is that we cannot check the composable’s state before the onMounted hook is executed within the setup function. Other tests might work as expected, but not this one.

For now, we’ll skip this test and focus on the others. We will later find a better solution to avoid onMounted side effects in our tests.

Alternative to Using withSetup

Another option is to define an empty component to wrap the composable:

const createComposableWrapper = () => {
  return mount(
    defineComponent({
      setup() {
        return useVersion();
      },
      template: "<div />", // A template is required by @vue/test-utils
    }),
  );
};

// Example usage:
it("should return the correct initial state with createComposableWrapper", () => {
  const wrapper = createComposableWrapper();
  const { isVisible } = wrapper.vm;
  expect(isVisible).toBe(false);
});

The downside is that accessing .vm is generally discouraged because it exposes internal component details.

Complete Composable Test (v4-3)

👉 Code: use-version-4-3.unit.spec.ts

// @vitest-environment nuxt
import { describe, it, expect } from "vitest";
import { useVersion } from "../composables/useVersion";
import pkg from "@@/package.json";
import type { App } from "vue";
import { createApp } from "vue";

// This is an integration test.

function withSetup<T>(composable: () => T): [T, App] {
  let result: T | null = null;
  const app = createApp({
    setup() {
      result = composable();
      return () => {}; // Render an empty element
    },
  });
  app.mount(document.createElement("div")); // Attach to the DOM
  return [result as T, app]; // Safe type assertion since result is set.
}

describe("useVersion", () => {
  // Issue with onMounted: we skip this test because onMounted is called during setup,
  // so we cannot verify the state before onMounted executes.
  it.skip("should return the correct initial state with withSetup", () => {
    const [result] = withSetup(() => useVersion());
    expect(result.version).toBe(pkg.version);
    expect(result.isVisible.value).toBe(false);
  });

  describe("should show the banner", () => {
    it("when the version is not stored", () => {
      localStorage.removeItem("app-version");
      const [result] = withSetup(() => useVersion());
      expect(result.isVisible.value).toBe(true);
    });

    // The localStorage value '0.0.1' differs from the app version '0.0.2'
    it("when the version differs from localStorage", () => {
      localStorage.setItem("app-version", "0.0.1");
      const [result] = withSetup(() => useVersion());
      expect(result.isVisible.value).toBe(true);
    });
  });

  describe("should hide the banner", () => {
    // When localStorage equals the app version '0.0.2'
    it("when the same version is stored", () => {
      localStorage.setItem("app-version", "0.0.2");
      const [result] = withSetup(() => useVersion());
      expect(result.isVisible.value).toBe(false);
    });

    // On closing the banner, the app version should be stored in localStorage
    it("and store the version in localStorage when closeBanner is called", () => {
      localStorage.setItem("app-version", "0.0.1");
      const [result] = withSetup(() => useVersion());
      result.close();
      expect(result.isVisible.value).toBe(false);
      expect(localStorage.getItem("app-version")).toBe("0.0.2");
    });
  });
});

Next Step

There are a few ways to improve this test further:

Improving this test by converting it into a unit test would be a valuable enhancement.

Decision Map

Let’s take a look at the current map (Open 🔎):

Decision Map Graph

Previous

Next