Skip to content
77 changes: 77 additions & 0 deletions docs/testing/unit-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ This guide covers patterns and examples for unit testing utilities, composables,
5. [Mocking Utility Functions](#mocking-utility-functions)
6. [Testing with Debounce and Throttle](#testing-with-debounce-and-throttle)
7. [Mocking Node Definitions](#mocking-node-definitions)
8. [Mocking Composables with Reactive State](#mocking-composables-with-reactive-state)

## Testing Vue Composables with Reactivity

Expand Down Expand Up @@ -253,3 +254,79 @@ it('should validate node definition', () => {
expect(validateComfyNodeDef(EXAMPLE_NODE_DEF)).not.toBeNull()
})
```

## Mocking Composables with Reactive State

When mocking composables that return reactive refs, define the mock implementation inline in `vi.mock()`'s factory function. This ensures stable singleton instances across all test invocations.

### Rules

1. **Define mocks in the factory function** — Create `vi.fn()` and `ref()` instances directly inside `vi.mock()`, not in `beforeEach`
2. **Use singleton pattern** — The factory runs once; all calls to the composable return the same mock object
3. **Access mocks per-test** — Call the composable directly in each test to get the singleton instance rather than storing in a shared variable
4. **Wrap in `vi.mocked()` for type safety** — Use `vi.mocked(service.method).mockResolvedValue(...)` when configuring
5. **Rely on `vi.resetAllMocks()`** — Resets call counts without recreating instances; ref values may need manual reset if mutated

### Pattern

```typescript
// Example from: src/platform/updates/common/releaseStore.test.ts
import { ref } from 'vue'

vi.mock('@/path/to/composable', () => {
const doSomething = vi.fn()
const isLoading = ref(false)
const error = ref<string | null>(null)
return {
useMyComposable: () => ({
doSomething,
isLoading,
error
})
}
})

describe('MyStore', () => {
beforeEach(() => {
vi.clearAllMocks()
})

it('should call the composable method', async () => {
const service = useMyComposable()
vi.mocked(service.doSomething).mockResolvedValue({ data: 'test' })

await store.initialize()

expect(service.doSomething).toHaveBeenCalledWith(expectedArgs)
})

it('should handle errors from the composable', async () => {
const service = useMyComposable()
vi.mocked(service.doSomething).mockResolvedValue(null)
service.error.value = 'Something went wrong'

await store.initialize()

expect(store.error).toBe('Something went wrong')
})
})
```

### Anti-patterns

```typescript
// ❌ Don't configure mock return values in beforeEach with shared variable
let mockService: { doSomething: Mock }
beforeEach(() => {
mockService = { doSomething: vi.fn() }
vi.mocked(useMyComposable).mockReturnValue(mockService)
})

// ❌ Don't auto-mock then override — reactive refs won't work correctly
vi.mock('@/path/to/composable')
vi.mocked(useMyComposable).mockReturnValue({ isLoading: ref(false) })
```

```
Comment on lines +317 to +330
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Remove the stray fenced block or add a language tag (MD040).
An empty fenced block without a language spec trips markdownlint.

🧹 Proposed fix
-vi.mocked(useMyComposable).mockReturnValue({ isLoading: ref(false) })
-```
-
-```
+vi.mocked(useMyComposable).mockReturnValue({ isLoading: ref(false) })
+```
🧰 Tools
🪛 markdownlint-cli2 (0.18.1)

330-330: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
In `@docs/testing/unit-testing.md` around lines 317 - 330, The markdown contains a
stray empty fenced code block that triggers MD040; remove the empty
triple-backtick block or give it a language tag and ensure the intended snippet
(the vi.mocked(useMyComposable).mockReturnValue({ isLoading: ref(false) }) line)
is inside a properly delimited fenced block (e.g., ```typescript) instead of
leaving an empty block; update the section around the examples referencing
mockService, useMyComposable, vi.mocked and beforeEach so there are no
standalone empty triple-backtick fences.


```
6 changes: 2 additions & 4 deletions src/components/graph/GraphCanvas.vue
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ import { app as comfyApp } from '@/scripts/app'
import { ChangeTracker } from '@/scripts/changeTracker'
import { IS_CONTROL_WIDGET, updateControlWidgetLabel } from '@/scripts/widgets'
import { useColorPaletteService } from '@/services/colorPaletteService'
import { newUserService } from '@/services/newUserService'
import { useNewUserService } from '@/services/useNewUserService'
import { storeToRefs } from 'pinia'

import { useBootstrapStore } from '@/stores/bootstrapStore'
Expand Down Expand Up @@ -457,11 +457,9 @@ onMounted(async () => {
// Register core settings immediately after settings are ready
CORE_SETTINGS.forEach(settingStore.addSetting)

// Wait for both i18n and newUserService in parallel
// (newUserService only needs settings, not i18n)
await Promise.all([
until(() => isI18nReady.value || !!i18nError.value).toBe(true),
newUserService().initializeIfNewUser(settingStore)
useNewUserService().initializeIfNewUser()
])
if (i18nError.value) {
console.warn(
Expand Down
22 changes: 9 additions & 13 deletions src/components/graph/SelectionToolbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
createMockCanvas,
createMockPositionable
} from '@/utils/__tests__/litegraphTestUtils'
import * as litegraphUtil from '@/utils/litegraphUtil'
import * as nodeFilterUtil from '@/utils/nodeFilterUtil'

function createMockExtensionService(): ReturnType<typeof useExtensionService> {
return {
Expand Down Expand Up @@ -289,9 +291,8 @@ describe('SelectionToolbox', () => {
)
})

it('should show mask editor only for single image nodes', async () => {
const mockUtils = await import('@/utils/litegraphUtil')
const isImageNodeSpy = vi.spyOn(mockUtils, 'isImageNode')
it('should show mask editor only for single image nodes', () => {
const isImageNodeSpy = vi.spyOn(litegraphUtil, 'isImageNode')

// Single image node
isImageNodeSpy.mockReturnValue(true)
Expand All @@ -307,9 +308,8 @@ describe('SelectionToolbox', () => {
expect(wrapper2.find('.mask-editor-button').exists()).toBe(false)
})

it('should show Color picker button only for single Load3D nodes', async () => {
const mockUtils = await import('@/utils/litegraphUtil')
const isLoad3dNodeSpy = vi.spyOn(mockUtils, 'isLoad3dNode')
it('should show Color picker button only for single Load3D nodes', () => {
const isLoad3dNodeSpy = vi.spyOn(litegraphUtil, 'isLoad3dNode')

// Single Load3D node
isLoad3dNodeSpy.mockReturnValue(true)
Expand All @@ -325,13 +325,9 @@ describe('SelectionToolbox', () => {
expect(wrapper2.find('.load-3d-viewer-button').exists()).toBe(false)
})

it('should show ExecuteButton only when output nodes are selected', async () => {
const mockNodeFilterUtil = await import('@/utils/nodeFilterUtil')
const isOutputNodeSpy = vi.spyOn(mockNodeFilterUtil, 'isOutputNode')
const filterOutputNodesSpy = vi.spyOn(
mockNodeFilterUtil,
'filterOutputNodes'
)
it('should show ExecuteButton only when output nodes are selected', () => {
const isOutputNodeSpy = vi.spyOn(nodeFilterUtil, 'isOutputNode')
const filterOutputNodesSpy = vi.spyOn(nodeFilterUtil, 'filterOutputNodes')

// With output node selected
isOutputNodeSpy.mockReturnValue(true)
Expand Down
8 changes: 3 additions & 5 deletions src/components/graph/selectionToolbox/ExecuteButton.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'

import ExecuteButton from '@/components/graph/selectionToolbox/ExecuteButton.vue'
import { useSelectionState } from '@/composables/graph/useSelectionState'
import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCommandStore } from '@/stores/commandStore'
Expand Down Expand Up @@ -47,7 +48,7 @@ describe('ExecuteButton', () => {
}
})

beforeEach(async () => {
beforeEach(() => {
// Set up Pinia with testing utilities
setActivePinia(
createTestingPinia({
Expand All @@ -71,10 +72,7 @@ describe('ExecuteButton', () => {
vi.spyOn(commandStore, 'execute').mockResolvedValue()

// Update the useSelectionState mock
const { useSelectionState } = vi.mocked(
await import('@/composables/graph/useSelectionState')
)
useSelectionState.mockReturnValue({
vi.mocked(useSelectionState).mockReturnValue({
selectedNodes: {
value: mockSelectedNodes
}
Expand Down
4 changes: 1 addition & 3 deletions src/composables/useTemplateFiltering.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { nextTick, ref } from 'vue'
import type { IFuseOptions } from 'fuse.js'

import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
import { useTemplateFiltering } from '@/composables/useTemplateFiltering'

const defaultSettingStore = {
get: vi.fn((key: string) => {
Expand Down Expand Up @@ -50,9 +51,6 @@ vi.mock('@/scripts/api', () => ({
}
}))

const { useTemplateFiltering } =
await import('@/composables/useTemplateFiltering')

describe('useTemplateFiltering', () => {
beforeEach(() => {
setActivePinia(createPinia())
Expand Down
9 changes: 3 additions & 6 deletions src/lib/litegraph/src/LGraph.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,9 @@ describe('LGraph', () => {
expect(graph.extra).toBe('TestGraph')
})

test('is exactly the same type', async ({ expect }) => {
const directImport = await import('@/lib/litegraph/src/LGraph')
const entryPointImport = await import('@/lib/litegraph/src/litegraph')

expect(LiteGraph.LGraph).toBe(directImport.LGraph)
expect(LiteGraph.LGraph).toBe(entryPointImport.LGraph)
test('is exactly the same type', ({ expect }) => {
// LGraph from barrel export and LiteGraph.LGraph should be the same
expect(LiteGraph.LGraph).toBe(LGraph)
})

test('populates optional values', ({ expect, minimalSerialisableGraph }) => {
Expand Down
28 changes: 9 additions & 19 deletions src/lib/litegraph/src/litegraph.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { clamp } from 'es-toolkit/compat'
import { beforeEach, describe, expect, vi } from 'vitest'
import { describe, expect } from 'vitest'

import {
LiteGraphGlobal,
LGraphCanvas,
LiteGraph
LiteGraph,
LGraph
} from '@/lib/litegraph/src/litegraph'

import { LGraph as DirectLGraph } from '@/lib/litegraph/src/LGraph'

import { test } from './__fixtures__/testExtensions'

describe('Litegraph module', () => {
Expand All @@ -27,22 +30,9 @@ describe('Litegraph module', () => {
})

describe('Import order dependency', () => {
beforeEach(() => {
vi.resetModules()
})

test('Imports without error when entry point is imported first', async ({
expect
}) => {
async function importNormally() {
const entryPointImport = await import('@/lib/litegraph/src/litegraph')
const directImport = await import('@/lib/litegraph/src/LGraph')

// Sanity check that imports were cleared.
expect(Object.is(LiteGraph, entryPointImport.LiteGraph)).toBe(false)
expect(Object.is(LiteGraph.LGraph, directImport.LGraph)).toBe(false)
}

await expect(importNormally()).resolves.toBeUndefined()
test('Imports reference the same types', ({ expect }) => {
// Both imports should reference the same LGraph class
expect(LiteGraph.LGraph).toBe(DirectLGraph)
expect(LiteGraph.LGraph).toBe(LGraph)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'

import { useMediaAssetActions } from './useMediaAssetActions'

// Use vi.hoisted to create a mutable reference for isCloud
const mockIsCloud = vi.hoisted(() => ({ value: false }))

Expand Down Expand Up @@ -126,7 +128,6 @@ describe('useMediaAssetActions', () => {
})

it('should use asset.name as filename', async () => {
const { useMediaAssetActions } = await import('./useMediaAssetActions')
const actions = useMediaAssetActions()

const asset = createMockAsset({
Expand All @@ -146,7 +147,6 @@ describe('useMediaAssetActions', () => {
})

it('should use asset_hash as filename when available', async () => {
const { useMediaAssetActions } = await import('./useMediaAssetActions')
const actions = useMediaAssetActions()

const asset = createMockAsset({
Expand All @@ -160,7 +160,6 @@ describe('useMediaAssetActions', () => {
})

it('should fall back to asset.name when asset_hash is not available', async () => {
const { useMediaAssetActions } = await import('./useMediaAssetActions')
const actions = useMediaAssetActions()

const asset = createMockAsset({
Expand All @@ -174,7 +173,6 @@ describe('useMediaAssetActions', () => {
})

it('should fall back to asset.name when asset_hash is null', async () => {
const { useMediaAssetActions } = await import('./useMediaAssetActions')
const actions = useMediaAssetActions()

const asset = createMockAsset({
Expand All @@ -196,7 +194,6 @@ describe('useMediaAssetActions', () => {
})

it('should use asset_hash for each asset', async () => {
const { useMediaAssetActions } = await import('./useMediaAssetActions')
const actions = useMediaAssetActions()

const assets = [
Expand Down
Loading