/ts-junit

use JUnit 5 Decorator in TypeScript

Primary LanguageTypeScriptMIT LicenseMIT

ts-junit

version license TypeScript npm total downloads npm month downloads PRs Welcome Github action

use JUnit 5 Decorator in TypeScript

在我看来,在 TypeScript 里使用面向对象是很大概率变成最常用的方式的。目前所有的 JavaScript 测试都是面向过程的,比如 qunit、jest、mocha、ava、tape 等测试框架实现,还是围绕在面向过程阶段。我以为这不是 TypeScript 在现实中该有的样式。

我对 Java 还算熟悉,比如使用 JUnit 5 的测试代码就是采用面向对象写法的,代码如下。

import static org.junit.jupiter.api.Assertions.assertEquals;
import example.util.Calculator;
import org.junit.jupiter.api.Test;

class MyFirstJUnitJupiterTests {

    private final Calculator calculator = new Calculator();

    @Test
    void addition() {
        assertEquals(2, calculator.add(1, 1));
    }
}

这种写法是非常简单的,这就是 Java 面向的好处。如果换成 TypeScript,几乎可以保持写法一模一样,代码如下。

import assert from "assert";
import { Test } from "ts-junit";

export default class MyFirstJUnitJupiterTests {
  calculator = new Calculator();

  @Test
  addition() {
    assert.is(2, calculator.add(1, 1));
  }
}

反观前端的测试代码基本上 2 种风格。前端的测试代码风格 1,它是最常用的测试方式,代码实例如下。

test("JSON", () => {
  const input = {
    foo: "hello",
    bar: "world",
  };

  const output = JSON.stringify(input);

  assert.snapshot(output, `{"foo":"hello","bar":"world"}`);
  assert.equal(JSON.parse(output), input, "matches original");
});

前端的测试代码风格 2,bdd 风格,它更强调行为对测试用例的影响,代码实例如下。

describe("User", function () {
  describe("#save()", function () {
    it("should save without error", function (done) {
      var user = new User("Luna");
      user.save(function (err) {
        if (err) throw err;
        done();
      });
    });
  });
});

对比一下 Java 和 JavaScript 测试多个写法之后,你会发现,面向对象在 JavaScript(TypeScript)里根本不是一等公民。于是我就萌发了一个想法,想用 TypeScript 实现一下 JUnit。

特性

  • jest 支持 ts 需要引入 babel
  • ts-jest 直接支持 ts,测试语法上是 jest 语法,suite/test 或 describe/it
  • ts-junit 支持 2 种用法,其中 cli 方式采用增量 ts 编译,效率很高的。
  • ts-junit 使用 junit 5 的装饰器进行封装,成熟,使用于熟悉 OO 的开发,尤其对了解 Java 的开发者更友好。
  • ts-junit 使用 uvu 作为默认策略,同时也可以实现各个常见测试框架的支持,比如 jest、mocha、ava、tape、qunit、jasmine 等(暂时未实现)。

示例

import assert from 'assert'
import { BeforeAll, BeforeEach, Disabled, Test, AfterEach, AfterAll } from 'ts-junit'

export default class MyFirstJUnitJupiterTests {
    calculator = new Calculator()

    @BeforeAll
    static void initAll() {
    }

    @BeforeEach
    void init() {
    }

    @Test
    void succeedingTest() {

    }

    @Test
    void failingTest() {
        assert.fail("a failing test");
    }

    @Test
    @Disabled("for demonstration purposes")
    void skippedTest() {
        // not executed
    }

    @Test
    void abortedTest() {
        assert.assumeTrue("abc".contains("Z"));
        assert.fail("test should have been aborted");
    }

    @AfterEach
    void tearDown() {
    }

    @AfterAll
    static void tearDownAll() {
    }
}

Usages

方式 1: 使用独立 cli 进行编译

不依赖当前项目的 ts 环境,直接通过 cli 执行,参考源码中 tests 目录下的文件。

$ npm i --global @ts-junit/cli
$ junit tests
$ junit tests/test.ts

编写第一个测试用例

import assert from 'assert'
import { Test } from '@ts-junit/core'

export default class MyFirstJUnitJupiterTests  {

    calculator = new Calculator();

    @Test
    void addition() {
        assert.is(2, calculator.add(1, 1));
    }
}

方式 2: 依赖当前项目的 ts 环境进行编译

$ npm i --save-dev @ts-junit/core

编写测试入口文件 ts-junit.ts,文件内指定测试文件或测试目录即可。

import * as path from "node:path";
import { run } from "@ts-junit/core";

const folder = path.resolve(process.cwd(), "./tests");
const file = path.resolve(process.cwd(), "./tests/test.ts");

run([folder, file]);
// or custom Strategy
// import SomeStrategy from "./SomeStrategy";
// run([folder, file], new SomeStrategy());

创建编译时的 tsconfig.json 文件

{
  "compileOnSave": true,
  "compilerOptions": {
    "target": "es2017",
    "module": "commonjs",
    "sourceMap": true,
    "outDir": "./build",
    "rootDir": "./src",
    "typeRoots": [],
    "types": [],
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  },
  "exclude": ["node_modules"],
  "include": ["./src/**/*.ts", "./test/**/*.ts"]
}

编辑 package.json 的启动和编译脚本

{
  "scripts": {
    "test": "NODE_ENV=dev ts-node --project tsconfig.json --files ts-junit.ts",
    "build": "tsc"
  }
}

启动服务

$ npm test
> NODE_ENV=dev ts-node --project tsconfig.json --files ts-junit.ts
[2020-9-1 19:52:12] [debug] [init] [router] get - /

装饰器

Annotation Description isSupported

@Test

Denotes that a method is a test method. Unlike JUnit 4’s @Test annotation, this annotation does not declare any attributes, since test extensions in JUnit Jupiter operate based on their own dedicated annotations. Such methods are inherited unless they are overridden.

@ParameterizedTest

Denotes that a method is a parameterized test. Such methods are inherited unless they are overridden.

@RepeatedTest

Denotes that a method is a test template for a repeated test. Such methods are inherited unless they are overridden.

@TestFactory

Denotes that a method is a test factory for dynamic tests. Such methods are inherited unless they are overridden.

@TestTemplate

Denotes that a method is a template for test cases designed to be invoked multiple times depending on the number of invocation contexts returned by the registered providers. Such methods are inherited unless they are overridden.

@TestMethodOrder

Used to configure the test method execution order for the annotated test class; similar to JUnit 4’s @FixMethodOrder. Such annotations are inherited.

@TestInstance

Used to configure the test instance lifecycle for the annotated test class. Such annotations are inherited.

@DisplayName

Declares a custom display name for the test class or test method. Such annotations are not inherited.

@DisplayNameGeneration

Declares a custom display name generator for the test class. Such annotations are inherited.

@BeforeEach

Denotes that the annotated method should be executed before each @Test, @RepeatedTest, @ParameterizedTest, or @TestFactory method in the current class; analogous to JUnit 4’s @Before. Such methods are inherited unless they are overridden.

@AfterEach

Denotes that the annotated method should be executed after each @Test, @RepeatedTest, @ParameterizedTest, or @TestFactory method in the current class; analogous to JUnit 4’s @After. Such methods are inherited unless they are overridden.

@BeforeAll

Denotes that the annotated method should be executed before all @Test, @RepeatedTest, @ParameterizedTest, and @TestFactory methods in the current class; analogous to JUnit 4’s @BeforeClass. Such methods are inherited (unless they are hidden or overridden) and must be static (unless the "per-class" test instance lifecycle is used).

@AfterAll

Denotes that the annotated method should be executed after all @Test, @RepeatedTest, @ParameterizedTest, and @TestFactory methods in the current class; analogous to JUnit 4’s @AfterClass. Such methods are inherited (unless they are hidden or overridden) and must be static (unless the "per-class" test instance lifecycle is used).

@Nested

Denotes that the annotated class is a non-static nested test class. @BeforeAll and @AfterAll methods cannot be used directly in a @Nested test class unless the "per-class" test instance lifecycle is used. Such annotations are not inherited.

@Tag

Used to declare tags for filtering tests, either at the class or method level; analogous to test groups in TestNG or Categories in JUnit 4. Such annotations are inherited at the class level but not at the method level.

@Disabled

Used to disable a test class or test method; analogous to JUnit 4’s @Ignore. Such annotations are not inherited.

@Timeout

Used to fail a test, test factory, test template, or lifecycle method if its execution exceeds a given duration. Such annotations are inherited.

@ExtendWith

Used to register extensions declaratively. Such annotations are inherited.

@RegisterExtension

Used to register extensions programmatically via fields. Such fields are inherited unless they are shadowed.

@TempDir

Used to supply a temporary directory via field injection or parameter injection in a lifecycle method or test method; located in the org.junit.jupiter.api.io package.

TODO

  1. 结合 https://github.com/midwayjs/injection 更简单(暂未实现)

    class Test {
      @Inject()
      helloTest: IHelloTest;
      @Inject()
      helloService: IHelloService;
    
      @Before()
      before() {
        mock(helloTest, "sayhello", () => {
          return "mocked";
        });
      }
    
      @Test()
      async test() {
        expect(this.helloTest.sayhello()).eq("mocked");
    
        expect(this.helloService.sayhello("test")).eq("hello test");
      }
    }
  2. use vm2 with require from memfs