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.jsautomatically adds this plugin whenNODE_ENV=test(which Jest sets by default). Without it, the.vuefile transformer fails to initialise, and every Vue component test suite will error before running.
Why pin
@babel/coreand@babel/preset-envto7.26.0? Starting with7.27.0,@babel/preset-envships a nested@babel/helper-compilation-targets@8(ESM) that requireslru-cache@^11, which is not installed by@rancher/shell. Using an exact pin of7.26.0avoids this broken dependency chain while remaining fully compatible with all other packages.
Peer dependency note:
ts-jest@29declarestypescript@>=4.3 <7as a peer. The@rancher/shellscaffold 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 installa second time to ensure no stale nestednode_modulesfrom 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 olderglobals['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": falseintsconfig.json. With source maps off,@vue/vue3-jest's TypeScript transformer produces an empty-stringinputSourceMap, which newer versions of@babel/corereject. Enabling source maps here (scoped to Jest only) fixes this without touchingtsconfig.json.isolatedModules: true— Speeds up compilation by skipping full type-checking during test runs. Type safety is enforced separately bytsc --noEmit.
Note on
configFile: false: This only applies to.jsfiles processed bybabel-jest. The@vue/vue3-jesttransformer has its own internal Babel invocation that still readsbabel.config.js— which is whybabel-plugin-transform-require-contextis required above. Do not modifybabel.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.tsfile is not needed.@rancher/shellalready provides the*.vuemodule declaration through its published types, which TypeScript picks up via the"shell"entry in thetypesarray 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
shallowMountovermount— 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