Unit Testing the Composable

In this seventh episode, we simplify testing by replacing `onMounted` with an explicit `init` method. This small refactor leads us to deeper questions about dependencies inside composables. Should we mock them—or inject them? We explore both approaches and their trade-offs.

8 min read
Share
Unit Testing the Composable

Refactor: Replace onMounted with an “init” Method in the Composable

Instead of using onMounted directly inside the composable, we now expose an init function. This change allows tests to control when the initialization logic runs.

👉 Code: useVersion.ts

export const useVersion = () => {
  // ...
  const init = () => {
    if (localStorage.getItem(VERSION_KEY) !== version) {
      isVisible.value = true;
    }
  };

  return {
    // ...
    init,
  };
};

We also update the test to call init explicitly:

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

Now, the first test will pass.

We should also update the parent component to call the init function:

👉 Code: VersionBanner05.vue

<script lang="ts" setup>
import { useVersion } from "../composables/useVersion";
const { isVisible, version, init, close } = useVersion();
onMounted(() => {
  init();
});
</script>

Mocking useRuntimeConfig (v5-1)

We can now mock useRuntimeConfig and centralize our test variables, eliminating the need for the withSetup utility function. This approach tests the composable directly.

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

// @vitest-environment happy-dom
import { describe, it, expect, beforeEach } from "vitest";
import { useVersion } from "../composables/useVersion";
// We no longer need the actual value from package.json.
import { mockNuxtImport } from "@nuxt/test-utils/runtime";

const VERSION_KEY = "app-version";
const CURRENT_VERSION = "0.0.8";
const STORED_VERSION = "0.0.7";

// Mock useRuntimeConfig from Nuxt
mockNuxtImport("useRuntimeConfig", () => {
  return () => ({
    public: {
      version: CURRENT_VERSION,
    },
  });
});

describe("useVersion", () => {
  beforeEach(() => {
    localStorage.removeItem(VERSION_KEY);
  });

  it("should return the correct initial state", () => {
    const { version, isVisible } = useVersion();
    expect(version).toBe(CURRENT_VERSION);
    expect(isVisible.value).toBe(false);
  });

  describe("should show the banner", () => {
    it("when no version is stored", () => {
      const { init, isVisible } = useVersion();
      init();
      expect(isVisible.value).toBe(true);
    });

    it("when the version in localStorage differs", async () => {
      localStorage.setItem(VERSION_KEY, STORED_VERSION);
      const { init, isVisible } = useVersion();
      init();
      expect(isVisible.value).toBe(true);
    });
  });

  describe("should hide the banner", () => {
    it("when the same version is stored", () => {
      localStorage.setItem(VERSION_KEY, CURRENT_VERSION);
      const { init, isVisible } = useVersion();
      init();
      expect(isVisible.value).toBe(false);
    });

    it("and update localStorage on closeBanner", () => {
      const { init, close, isVisible } = useVersion();
      init();
      close();
      expect(isVisible.value).toBe(false);
      expect(localStorage.getItem(VERSION_KEY)).toBe(CURRENT_VERSION);
    });
  });
});

Benefits of This Refactoring:

Note: This is still an integration test because it relies on localStorage.

Mocking localStorage (v5-2)

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

To further isolate our test and transform it into a true unit test, we can also mock localStorage:

// @vitest-environment happy-dom
import { describe, it, expect, beforeEach, vi } from "vitest";
import type { App } from "vue";
import { createApp } from "vue";
import { useVersion } from "../composables/useVersion";

// Mock localStorage
const mockLocalStorage = (() => {
  let store: Record<string, string> = {};
  return {
    getItem: vi.fn((key: string) => store[key] || null),
    setItem: vi.fn((key: string, value: string) => {
      store[key] = value.toString();
    }),
    removeItem: vi.fn((key: string) => {
      delete store[key];
    }),
  };
})();

// Replace the global localStorage with our mock.
Object.defineProperty(window, "localStorage", {
  value: mockLocalStorage,
});

const VERSION_KEY = "app-version";
const CURRENT_VERSION = "0.0.8";
const STORED_VERSION = "0.0.7";

// Mock useRuntimeConfig from Nuxt using a different technique to avoid errors.
vi.mock("#app/nuxt", () => ({
  useRuntimeConfig: () => ({
    public: {
      version: CURRENT_VERSION,
    },
  }),
}));

describe("useVersion", () => {
  beforeEach(() => {
    mockLocalStorage.removeItem(VERSION_KEY);
    vi.clearAllMocks();
  });

  it("should return the correct initial state", () => {
    const { version, isVisible } = useVersion();
    expect(version).toBe(CURRENT_VERSION);
    expect(isVisible.value).toBe(false);
    expect(mockLocalStorage.getItem).not.toHaveBeenCalled();
  });

  describe("should show the banner", () => {
    it("when no version is stored", () => {
      const { init, isVisible } = useVersion();
      init();
      expect(isVisible.value).toBe(true);
      expect(mockLocalStorage.getItem).toHaveBeenCalledWith(VERSION_KEY);
    });

    it("when the version in localStorage differs", () => {
      mockLocalStorage.getItem.mockReturnValueOnce(STORED_VERSION);
      const { init, isVisible } = useVersion();
      init();
      expect(isVisible.value).toBe(true);
      expect(mockLocalStorage.getItem).toHaveBeenCalledWith(VERSION_KEY);
    });
  });

  describe("should hide the banner", () => {
    it("when the same version is stored", () => {
      mockLocalStorage.getItem.mockReturnValueOnce(CURRENT_VERSION);
      const { init, isVisible } = useVersion();
      init();
      expect(isVisible.value).toBe(false);
      expect(mockLocalStorage.getItem).toHaveBeenCalledWith(VERSION_KEY);
    });

    it("and update localStorage on closeBanner", () => {
      const { init, isVisible, close } = useVersion();
      init();
      close();
      expect(isVisible.value).toBe(false);
      expect(mockLocalStorage.setItem).toHaveBeenCalledWith(
        VERSION_KEY,
        CURRENT_VERSION,
      );
    });
  });
});

Note: We encountered an error when mocking the Nuxt import:

“Error: [vitest] There was an error when mocking a module… Caused by: TypeError: Cannot read properties of undefined (reading ‘#app/nuxt’)”

This error suggests that multiple mocks may conflict. We need to adjust our approach to mock #app/nuxt so that it works together with our localStorage mock.

What’s Wrong?

The final result is a true unit test, where we test the composable in isolation. However, there are some concerns:

Next Step

We consistently face the same issue: Is it the composable’s responsibility to import dependencies like localStorage and runtimeConfig? Would it be better to add another layer of encapsulation by injecting these dependencies as parameters? That way, the composable focuses solely on business logic, and our tests become simpler and more focused.

What do you think?

Decision Map

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

Decision Map Graph

Previous

Next