How to use vitest on Visual Stduio TestExplorer

The fact that you read this title and opened this page means I'm sure that you know what is esproj.

https://learn.microsoft.com/en-us/visualstudio/javascript/unit-testing-javascript-with-visual-studio?view=vs-2022&tabs=jest

esproj is supporting only the following test frameworks.

  • Mocha (mochajs.org)
  • Jasmine (Jasmine.github.io)
  • Tape (github.com/substack/tape)
  • Jest (jestjs.io)

But, In 2024, vitest is one of the most popular frameworks for testing.

I want to use it.

1. Install framework script

Create a js file at <VSInstallDir>\Common7\IDE\Extensions\Microsoft\JavaScript\TestFrameworks\Vitest\vitest.js

In my case, <VSInstallDir> is C:\Program Files\Microsoft Visual Studio\2022\Enterprise.

If <VSInstallDir>Common7\IDE\Extensions\Microsoft\JavaScript\TestFrameworks doesn't exist, you must install Visual Studio NodeJS Developer Tools in first.

And copy the next code to the js file you create.

// @ts-check
"use strict";
const fs = require('fs');
const os = require('os');
const path = require('path');

function normalize(file) {
  file = file.replaceAll('\\', '/')

  if (fs.existsSync(file)) {
    return file
  }

  const ext = path.extname(file)
  const name = file.slice(0, file.length - ext.length)

  const ts = name + '.ts'
  if (fs.existsSync(ts)) {
    return ts
  }

  const tsx = name + '.tsx'
  if (fs.existsSync(tsx)) {
    return tsx
  }

  const jsx = name + '.jsx'
  if (fs.existsSync(jsx)) {
    return jsx
  }

  return file
}

class VSTestsCollector {
  constructor(callback) {
    this.callback = callback
    this.tests = []
  }
  init(ctx) {
    this.ctx = ctx
  }

  onFinished(files, errors, coverage) {
    this.ctx.close()
    this.callback?.(this.tests)
  }

  onCollected(files) {
    const visit = (items, prefix = null) => {
      for (let item of items) {
        if (item.type === 'test') {
          this.tests.push({
            filepath: item.file.filepath,
            line: item.location.line - 1,
            column: item.location.column,
            suite: prefix,
            name: item.name
          })
        }
        if (item.type === 'suite') {
          visit(item.tasks, `${prefix ?? ''} ${item.name}`.trimStart())
        }
      }
    }
    visit(files)
  }
}

function collectTests(projectFolder, files) {
  return new Promise(function (resolve) {
    (async function () {
      const vitestModule = await detectPackage(projectFolder, 'vitest', 'dist/node.js');

      if (!vitestModule) {
        console.log('vitest is not found');
        return;
      }

      const reporter = new VSTestsCollector(resolve);

      const ctx = await vitestModule.createVitest('test', {
        dir: projectFolder,
        config: null,
        workspace: null,
        watch: true,
        api: false,
        reporter: undefined,
        reporters: [reporter],
        ui: false,
        includeTaskLocation: true,
      }, {
        middlewareMode: true,
      })

      reporter.init(ctx)

      await ctx.getTestFilepaths() // must

      ctx.runFiles(files.flatMap(file => ctx.getProjectsByTestFile(normalize(file))), false)
    }())
  })
}

const find_tests = function (testFileList, discoverResultFile, projectFolder) {
  return collectTests(projectFolder, testFileList.split(';')).then(tests => {
    const fd = fs.openSync(discoverResultFile, 'w')
    fs.writeSync(fd, JSON.stringify(tests))
    fs.closeSync(fd)
  })
};


class VSResultsReporter {
  constructor(callback) {
    this.callback = callback
    this.tests = []
  }

  init(ctx) {
    this.ctx = ctx
  }

  onFinished(files, errors, coverage) {
    const visit = (items, prefix = '') => {
      for (let item of items) {
        const name = `${prefix} ${item.name}`.trimStart()
        if (item.type === 'test') {
          this.tests.push({
            type: item.result.state === 'skip' ? 'pending' : 'result',
            fullyQualifiedName: name,
            result: {
              passed: item.result.state === 'pass',
              pending: item.result.state === 'skip',
              stderr: item.result.errors?.map(err => err.message).join('\n') ?? '',
              stdout: '',
              fullyQualifiedName: name
            }
          })
        }
        if (item.type === 'suite') {
          visit(item.tasks, name)
        }
      }
    }
    visit(files)

    this.ctx.close()
    this.callback?.(this.tests)
  }
}

async function runTests(projectFolder, files) {
  return new Promise(function (resolve) {
    (async function () {
      const vitestModule = await detectPackage(projectFolder, 'vitest', 'dist/node.js');

      if (!vitestModule) {
        console.log('vitest is not found');
        return;
      }

      const reporter = new VSResultsReporter(resolve);

      const ctx = await vitestModule.createVitest('test', {
        dir: projectFolder,
        config: null,
        workspace: null,
        watch: true,
        api: false,
        reporter: undefined,
        reporters: [reporter],
        ui: false,
        includeTaskLocation: true,
      }, {
        middlewareMode: true,
      })

      reporter.init(ctx)

      await ctx.getTestFilepaths() // must

      ctx.runFiles(files.flatMap(file => ctx.getProjectsByTestFile(normalize(file))), false)
    }())
  })
}

const run_tests = function (context) {
  const projectFolder = context.testCases[0].projectFolder;

  // NODE_ENV sets the environment context for Node, it can be development, production or test and it needs to be set up for jest to work.
  // If no value was assigned, assign test.
  process.env.NODE_ENV = process.env.NODE_ENV || 'test';
  return (async function () {
    // Start all test cases, as jest is unable to filter out independently
    for (const testCase of context.testCases) {
      context.post({
        type: 'test start',
        fullyQualifiedName: testCase.fullyQualifiedName
      });
    }

    try {
      const results = await runTests(projectFolder, context.testCases.map(testCase => testCase.testFile))

      for (let item of results) {
        item.fullyQualifiedName = context.getFullyQualifiedName(item.fullyQualifiedName)
        item.result.fullyQualifiedName = item.fullyQualifiedName
        context.post(item);
      }
    } catch (error) {
      logError(error);
    }
    context.post({
      type: 'end'
    });
  }())
};


async function detectPackage(projectFolder, packageName, file) {
  try {
    const packagePath = path.join(projectFolder, 'node_modules', packageName, file);
    const pkg = await import(`file:///${packagePath}`);

    return pkg;
  } catch (ex) {
    console.log(ex)
    logError(
      `Failed to find "${packageName}" package. "${packageName}" must be installed in the project locally.` + os.EOL +
      `Install "${packageName}" locally using the npm manager via solution explorer` + os.EOL +
      `or with ".npm install ${packageName} --save-dev" via the Node.js interactive window.`);
    return null;
  }
}

function logError() {
  var errorArgs = Array.from(arguments);
  errorArgs.unshift("NTVS_ERROR:");
  console.error.apply(console, errorArgs);
}

module.exports.find_tests = find_tests;
module.exports.run_tests = run_tests;

2. Set JavaScriptTestFramework Vitest

<Project Sdk="Microsoft.VisualStudio.JavaScript.Sdk/1.0.1147595">
  <PropertyGroup>
    ...
    <JavaScriptTestFramework>Vitest</JavaScriptTestFramework>
    ...
  </PropertyGroup>
</Project>

3. Run all tests on TestExplorer

Have fun :)


You'll only receive email when they publish something new.

More from iwate
All posts