We use cookies to enhance your experience on the site
CodeWorlds

Testing Composables

Every critical system at NOVA LAB station must pass rigorous testing before deployment. The same goes for composables - we need to be sure they work correctly in every scenario.

Setting Up the Test Environment

For testing composables we use

@vue/test-utils
and Vitest (or Jest). The key is a helper function that runs a composable inside a test component.

1// test-utils.ts
2import { createApp, defineComponent } from 'vue'
3
4// Helper: run composable in an isolated component
5function withSetup(composable) {
6  let result
7
8  const TestComponent = defineComponent({
9    setup() {
10      result = composable()
11      return () => {} // empty render
12    }
13  })
14
15  const app = createApp(TestComponent)
16  app.mount(document.createElement('div'))
17
18  return { result, app }
19}

Testing a Simple Composable

1import { describe, it, expect } from 'vitest'
2import { ref } from 'vue'
3
4// Composable to test
5function useCounter(initial = 0) {
6  const count = ref(initial)
7  const increment = () => count.value++
8  const decrement = () => count.value--
9  const reset = () => { count.value = initial }
10  return { count, increment, decrement, reset }
11}
12
13describe('useCounter', () => {
14  it('starts with initial value', () => {
15    const { result } = withSetup(() => useCounter(10))
16    expect(result.count.value).toBe(10)
17  })
18
19  it('defaults to 0', () => {
20    const { result } = withSetup(() => useCounter())
21    expect(result.count.value).toBe(0)
22  })
23
24  it('increments value', () => {
25    const { result } = withSetup(() => useCounter())
26    result.increment()
27    expect(result.count.value).toBe(1)
28    result.increment()
29    expect(result.count.value).toBe(2)
30  })
31
32  it('decrements value', () => {
33    const { result } = withSetup(() => useCounter(5))
34    result.decrement()
35    expect(result.count.value).toBe(4)
36  })
37
38  it('resets to initial value', () => {
39    const { result } = withSetup(() => useCounter(10))
40    result.increment()
41    result.increment()
42    result.reset()
43    expect(result.count.value).toBe(10)
44  })
45})

Mocking Reactive Dependencies

When a composable depends on external data sources, we mock them.

1import { describe, it, expect, vi } from 'vitest'
2import { ref, nextTick } from 'vue'
3
4// Composable with fetch dependency
5function useSensorData(fetchFn) {
6  const data = ref(null)
7  const error = ref(null)
8  const loading = ref(false)
9
10  async function load(sensorId) {
11    loading.value = true
12    error.value = null
13    try {
14      data.value = await fetchFn(sensorId)
15    } catch (e) {
16      error.value = e.message
17    } finally {
18      loading.value = false
19    }
20  }
21
22  return { data, error, loading, load }
23}
24
25describe('useSensorData', () => {
26  it('loads data from server', async () => {
27    // Mock fetch function
28    const mockFetch = vi.fn().mockResolvedValue({
29      id: 'T-01',
30      temperature: 22,
31      status: 'active'
32    })
33
34    const { result } = withSetup(() => useSensorData(mockFetch))
35
36    expect(result.loading.value).toBe(false)
37
38    await result.load('T-01')
39
40    expect(mockFetch).toHaveBeenCalledWith('T-01')
41    expect(result.data.value).toEqual({
42      id: 'T-01',
43      temperature: 22,
44      status: 'active'
45    })
46    expect(result.loading.value).toBe(false)
47  })
48
49  it('handles errors', async () => {
50    const mockFetch = vi.fn().mockRejectedValue(
51      new Error('Server unavailable')
52    )
53
54    const { result } = withSetup(() => useSensorData(mockFetch))
55
56    await result.load('T-01')
57
58    expect(result.error.value).toBe('Server unavailable')
59    expect(result.data.value).toBeNull()
60  })
61})

Testing Composables with watch

Watchers are asynchronous - we need

nextTick
or
flushPromises
.

1import { describe, it, expect } from 'vitest'
2import { ref, watch, nextTick, computed } from 'vue'
3
4function useSearchFilter(items) {
5  const query = ref('')
6  const filtered = computed(() => {
7    if (!query.value) return items.value
8    return items.value.filter(item =>
9      item.name.toLowerCase().includes(query.value.toLowerCase())
10    )
11  })
12
13  return { query, filtered }
14}
15
16describe('useSearchFilter', () => {
17  it('filters items by query', async () => {
18    const items = ref([
19      { name: 'Temperature sensor' },
20      { name: 'Pressure sensor' },
21      { name: 'Radiation sensor' }
22    ])
23
24    const { result } = withSetup(() => useSearchFilter(items))
25
26    // Initially all items
27    expect(result.filtered.value).toHaveLength(3)
28
29    // Filtering
30    result.query.value = 'temp'
31    await nextTick()
32
33    expect(result.filtered.value).toHaveLength(1)
34    expect(result.filtered.value[0].name).toBe('Temperature sensor')
35  })
36
37  it('returns all for empty query', async () => {
38    const items = ref([{ name: 'A' }, { name: 'B' }])
39    const { result } = withSetup(() => useSearchFilter(items))
40
41    result.query.value = 'A'
42    await nextTick()
43    expect(result.filtered.value).toHaveLength(1)
44
45    result.query.value = ''
46    await nextTick()
47    expect(result.filtered.value).toHaveLength(2)
48  })
49})

Testing Async Composables

1import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2import { ref, onMounted, onUnmounted } from 'vue'
3
4function usePolling(fetchFn, interval = 5000) {
5  const data = ref(null)
6  const isPolling = ref(false)
7  let timerId = null
8
9  function startPolling() {
10    isPolling.value = true
11    timerId = setInterval(async () => {
12      data.value = await fetchFn()
13    }, interval)
14  }
15
16  function stopPolling() {
17    isPolling.value = false
18    if (timerId) {
19      clearInterval(timerId)
20      timerId = null
21    }
22  }
23
24  onUnmounted(stopPolling)
25
26  return { data, isPolling, startPolling, stopPolling }
27}
28
29describe('usePolling', () => {
30  beforeEach(() => {
31    vi.useFakeTimers()
32  })
33
34  afterEach(() => {
35    vi.restoreAllTimers()
36  })
37
38  it('polls the server at specified intervals', async () => {
39    let callCount = 0
40    const mockFetch = vi.fn().mockImplementation(() => {
41      callCount++
42      return Promise.resolve({ reading: callCount * 10 })
43    })
44
45    const { result } = withSetup(() => usePolling(mockFetch, 1000))
46
47    result.startPolling()
48    expect(result.isPolling.value).toBe(true)
49
50    // Simulate time passing
51    await vi.advanceTimersByTime(3000)
52
53    expect(mockFetch).toHaveBeenCalledTimes(3)
54
55    result.stopPolling()
56    expect(result.isPolling.value).toBe(false)
57  })
58})

Best Practices for Testing

  1. Isolation - each test should be independent
  2. Mocking - external dependencies should always be mocked
  3. Asynchrony - use
    nextTick
    and
    flushPromises
    for reactivity
  4. Cleanup - verify that the composable properly cleans up resources
  5. Edge cases - test empty data, errors, boundary values
Go to CodeWorlds