配置 watch 在保存测试文件后仅运行相关用例

解读

国内前端团队普遍采用“单测先行+持续集成”模式,本地开发阶段要求“保存即测试”以缩短反馈闭环。Grunt 的 grunt-contrib-watch 默认监听文件变化后执行预定义任务,但不会区分是哪一类文件触发的变更,导致每次保存都跑完整测试套件,耗时且干扰开发节奏。面试官想考察候选人能否在不破坏现有任务链的前提下,利用 Grunt 的动态任务注册与文件路径解析能力,实现“仅运行与改动文件对应的测试用例”这一精细化构建策略,体现对插件生态、任务流设计及性能优化的深度理解。

知识点

  1. grunt-contrib-watch 的事件钩子eventchangedFiles 属性可拿到触发变更的绝对路径列表。
  2. grunt.config.merge():运行时动态重写任务配置,避免二次启动 Grunt 进程。
  3. Mocha/Jest 的 grep/—testNamePattern 参数:通过文件路径推导测试套件名或用例名,实现精准筛选
  4. 文件路径到测试名的映射规则:国内主流约定 src/__tests__/**/*.test.jssrc/**/*.js 一一对应,需用glob 同步库做双向解析。
  5. 任务别名与任务函数:使用 grunt.registerTask('test:single', function (target) { … }) 接收外部参数,实现可复用单测任务
  6. 并发与缓存:若项目使用 grunt-concurrent,需把单测任务标记为独立进程,防止缓存污染。
  7. 国内镜像加速npm i -D grunt-contrib-watch@latest --registry=https://registry.npmmirror.com,确保插件版本与文档一致。

答案

  1. 安装依赖

    npm i -D grunt-contrib-watch grunt-mocha-test glob
    
  2. 在 Gruntfile.js 中定义“动态单测任务

    module.exports = function (grunt) {
      grunt.initConfig({
        mochaTest: {
          all: { src: ['test/**/*.test.js'] },
          // 占位配置,运行时被覆盖
          single: { options: { quiet: false }, src: [] }
        },
        watch: {
          testFiles: {
            files: ['src/**/*.js', 'test/**/*.test.js'],
            tasks: [],          // 关键:先置空,由事件钩子接管
            options: {
              spawn: false,     // 共享 Grunt 上下文
              event: ['changed'],
              debounceDelay: 300,
              // 核心钩子
              nospawn: true,
              livereload: false,
              eventCallback: function (grunt, _, filepath) {
                // 1. 拿到变更列表
                const changed = grunt.config.get('changedFiles') || [];
                changed.push(filepath);
                grunt.config.set('changedFiles', changed);
              }
            }
          }
        }
      });
    
      // 2. 注册动态任务
      grunt.registerTask('test:single', '运行与变更文件相关的用例', function () {
        const path = require('path');
        const glob = require('glob');
        const changed = grunt.config.get('changedFiles') || [];
        if (!changed.length) return grunt.log.ok('无变更文件,跳过测试');
    
        // 3. 路径映射:src/foo.js -> test/foo.test.js
        const testFiles = changed.reduce((acc, f) => {
          if (f.endsWith('.test.js')) {
            acc.push(f);
          } else if (f.startsWith('src')) {
            const name = path.basename(f, '.js');
            const pattern = `test/**/${name}.test.js`;
            acc.push(...glob.sync(pattern));
          }
          return acc;
        }, []);
    
        if (!testFiles.length) return grunt.log.warn('未匹配到对应测试文件');
    
        // 4. 动态重写 mochaTest.single 配置
        grunt.config.set('mochaTest.single.src', testFiles);
    
        // 5. 运行重写后的任务
        grunt.task.run('mochaTest:single');
      });
    
      // 6. 监听完成后统一触发
      grunt.event.on('watch', function (action, filepath) {
        grunt.config.set('changedFiles', [filepath]);
        grunt.task.run('test:single');
      });
    
      grunt.loadNpmTasks('grunt-contrib-watch');
      grunt.loadNpmTasks('grunt-mocha-test');
    
      grunt.registerTask('default', ['watch']);
    };
    
  3. 启动命令

    npx grunt watch
    

    保存 src/utils.js 后,控制台仅输出 test/utils.test.js 的执行结果,全量套件不会运行

拓展思考

  1. 多文件批量保存:若 IDE 支持“保存全部”,changedFiles 会累积多条路径,可在 test:single 任务内做去重+排序,再决定是否合并跑测试或并行跑。
  2. 与 Git 暂存区联动:利用 git diff --name-only 拿到仅本次改动的文件,实现“提交前单测”钩子,防止漏测。
  3. 结合 webpack 热更新:在微前端仓库里,子应用独立打包,可在 watch 回调里调用 webpack --config webpack.test.js --entry ${testEntry},实现零配置子应用单测
  4. CI 场景复用:把 test:single 封装成 grunt test --filter=filepath 命令,Jenkins/GitHub Actions 在 PR 阶段只跑增量用例,平均节省 40% 流水线时间。
  5. 未来迁移到 Vite/Rollup:Grunt 作为轻量任务编排器仍可用于“监听+触发”,把实际测试执行交给 vitest --run --reporter=json,实现老项目渐进式升级,保护历史资产的同时享受新工具性能红利。