Skip to main content
Version: v3

Unit Testing

Extensions use Jest for unit tests. The development app generated by npm init does not include test infrastructure by default — the steps below set it up.

Setup

1. Install test dependencies

From the root of your development app:

yarn add --dev \
jest@^29 jest-environment-jsdom@^29 \
ts-jest@^29 babel-jest@^29 \
@vue/vue3-jest@^29 @vue/test-utils@^2 \
@babel/[email protected] @babel/[email protected] \
@types/jest@^29 \
babel-plugin-transform-require-context

Why babel-plugin-transform-require-context? @rancher/shell/babel.config.js automatically adds this plugin when NODE_ENV=test (which Jest sets by default). Without it, the .vue file transformer fails to initialise, and every Vue component test suite will error before running.

Why pin @babel/core and @babel/preset-env to 7.26.0? Starting with 7.27.0, @babel/preset-env ships a nested @babel/helper-compilation-targets@8 (ESM) that requires lru-cache@^11, which is not installed by @rancher/shell. Using an exact pin of 7.26.0 avoids this broken dependency chain while remaining fully compatible with all other packages.

Peer dependency note: ts-jest@29 declares typescript@>=4.3 <7 as a peer. The @rancher/shell scaffold currently ships TypeScript 5.x, so yarn may warn but will still install correctly — the version is within the supported range.

After installing, run yarn install a second time to ensure no stale nested node_modules from a prior install are left inside @babel/preset-env/node_modules/. This is a yarn v1 quirk: it may not evict old nested packages when downgrading a dependency.

2. Create jest.config.js

Create jest.config.js at the root of your development app:

// jest.config.js
module.exports = {
testEnvironment: 'jsdom',
testEnvironmentOptions: { customExportConditions: ['node', 'node-addons'] },
watchman: false,
moduleFileExtensions: ['js', 'ts', 'json', 'vue'],
modulePaths: ['<rootDir>'],
moduleNameMapper: {
'^~/(.*)$': '<rootDir>/$1',
'^@/(.*)$': '<rootDir>/$1',
'@shell/(.*)': '<rootDir>/node_modules/@rancher/shell/$1',
'@components/(.*)': '<rootDir>/node_modules/@rancher/shell/rancher-components/$1',
'\\.(jpe?g|png|gif|webp|svg|mp4|webm|ogg|mp3|wav|flac|aac|woff2?|eot|ttf|otf)$': '<rootDir>/utils/svgTransform.js',
},
modulePathIgnorePatterns: ['<rootDir>/cypress/'],
transform: {
'^.+\\.vue$': '@vue/vue3-jest',
'^.+\\.tsx?$': ['ts-jest', { tsconfig: { sourceMap: true }, isolatedModules: true }],
'^.+\\.jsx?$': ['babel-jest', {
configFile: false,
presets: [['@babel/preset-env', { targets: { node: 'current' } }]]
}],
'^.+\\.svg$': '<rootDir>/utils/svgTransform.js'
},
transformIgnorePatterns: ['/node_modules/(?!@rancher/shell).+\\.js$'],
testMatch: ['**/__tests__/**/*.test.ts']
};

Why inline ts-jest options in transform? The older globals['ts-jest'] block format was deprecated in ts-jest v28 and will be removed in v30. The correct way to pass ts-jest options is as the second element of the transform tuple:

'^.+\\.tsx?$': ['ts-jest', { tsconfig: { sourceMap: true }, isolatedModules: true }]
  • sourceMap: true — The extension scaffold sets "sourceMap": false in tsconfig.json. With source maps off, @vue/vue3-jest's TypeScript transformer produces an empty-string inputSourceMap, which newer versions of @babel/core reject. Enabling source maps here (scoped to Jest only) fixes this without touching tsconfig.json.
  • isolatedModules: true — Speeds up compilation by skipping full type-checking during test runs. Type safety is enforced separately by tsc --noEmit.

Note on configFile: false: This only applies to .js files processed by babel-jest. The @vue/vue3-jest transformer has its own internal Babel invocation that still reads babel.config.js — which is why babel-plugin-transform-require-context is required above. Do not modify babel.config.js — it is required for the webpack dev server and build.

3. Create utils/svgTransform.js

Create utils/svgTransform.js at the root of your development app. This stubs out SVG imports so they don't break Jest:

// utils/svgTransform.js
module.exports = {
process() { return { code: 'module.exports = {};' }; },
getCacheKey() { return 'svgTransform'; },
};

4. Add test scripts to package.json

"scripts": {
"test": "jest",
"test:ci": "NODE_OPTIONS=--max_old_space_size=8192 jest --silent"
}

5. Add @types/jest to tsconfig.json

In the root tsconfig.json, add "@types/jest" to the types array:

"types": ["@types/node", "cypress", "rancher", "shell", "@types/jest"]

Note: A shims-vue.d.ts file is not needed. @rancher/shell already provides the *.vue module declaration through its published types, which TypeScript picks up via the "shell" entry in the types array above.


Writing Tests

Tests live in __tests__ directories placed next to the code they test. Files must be named *.test.ts.

pkg/
my-app/
composables/
useGreeting.ts
__tests__/
useGreeting.test.ts ← composable test
components/
MyComponent.vue
__tests__/
MyComponent.test.ts ← Vue component test

Composable

pkg/my-app/composables/useGreeting.ts:

import { computed, ref } from 'vue';
import type { ComputedRef, Ref } from 'vue';

interface UseGreeting {
name: Ref<string>;
greeting: ComputedRef<string>;
}

export function useGreeting(initialName = ''): UseGreeting {
const name = ref(initialName);
const greeting = computed(() => `Hello, ${ name.value }!`);

return { name, greeting };
}

Composable test

pkg/my-app/composables/__tests__/useGreeting.test.ts:

import { describe, it, expect } from '@jest/globals';
import { useGreeting } from '../useGreeting';

describe('useGreeting', () => {
it('returns a greeting with the initial name', () => {
const { greeting } = useGreeting('World');

expect(greeting.value).toStrictEqual('Hello, World!');
});

it('handles empty string', () => {
const { greeting } = useGreeting('');

expect(greeting.value).toStrictEqual('Hello, !');
});

it('updates greeting reactively when name changes', () => {
const { name, greeting } = useGreeting('Alex');

name.value = 'World';

expect(greeting.value).toStrictEqual('Hello, World!');
});
});

Vue component

pkg/my-app/components/MyComponent.vue:

<template>
<div>
<h1>{{ title }}</h1>
<p>{{ greeting }}</p>
</div>
</template>

<script lang="ts">
import { defineComponent, computed } from 'vue';
import { useGreeting } from '../composables/useGreeting';

export default defineComponent({
name: 'MyComponent',
props: {
title: {
type: String,
required: true,
},
},
setup(props) {
const { greeting } = useGreeting(props.title);

return { greeting: computed(() => greeting.value) };
},
});
</script>

Vue component test

pkg/my-app/components/__tests__/MyComponent.test.ts:

import { shallowMount } from '@vue/test-utils';
import MyComponent from '../MyComponent.vue';

describe('MyComponent', () => {
it('renders the title prop', () => {
const wrapper = shallowMount(MyComponent, {
props: { title: 'Hello' }
});

expect(wrapper.find('h1').text()).toStrictEqual('Hello');
});
});

Tip: Prefer shallowMount over mount — it stubs child components, isolating the component under test and avoiding unresolved dependency errors from shell or other child components.


Running Tests

# Run all tests
yarn test

# CI mode (higher memory limit, silent output)
yarn test:ci

# Run a single file
yarn test:ci pkg/my-app/composables/__tests__/useGreeting.test.ts

GitHub Actions Workflow

Create .github/workflows/unit-tests.yml to run tests automatically on every pull request:

name: Unit Tests

on:
push:
branches:
- main
pull_request:

jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: 'yarn'

- name: Install dependencies
run: yarn install --frozen-lockfile

- name: Run unit tests
run: yarn test:ci