Skip to content

Commit

Permalink
feat(core): Extension mechanism (#81)
Browse files Browse the repository at this point in the history
  • Loading branch information
JoseLion authored Feb 5, 2023
1 parent 149ad14 commit 439eba3
Show file tree
Hide file tree
Showing 25 changed files with 320 additions and 36 deletions.
57 changes: 53 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
[![All Contributors](https://img.shields.io/badge/all_contributors-9-orange.svg?style=flat-square)](#contributors-)
[![All Contributors](https://img.shields.io/badge/all_contributors-9-orange.svg)](#contributors-)
<!-- ALL-CONTRIBUTORS-BADGE:END -->
[![Build](https://github.com/stackbuilders/assertive-ts/actions/workflows/build.yml/badge.svg)](https://github.com/stackbuilders/assertive-ts/actions/workflows/build.yml)
[![npm version](https://badge.fury.io/js/@stackbuilders%2Fassertive-ts.svg)](https://badge.fury.io/js/@stackbuilders%2Fassertive-ts)
[![NPM version](https://img.shields.io/npm/v/@stackbuilders/assertive-ts)](https://www.npmjs.com/package/@stackbuilders/assertive-ts)
[![NPM bundle size](https://img.shields.io/bundlephobia/min/@stackbuilders/assertive-ts)](https://www.npmjs.com/package/@stackbuilders/assertive-ts)
[![NPM downloads](https://img.shields.io/npm/dm/@stackbuilders/assertive-ts)](https://www.npmjs.com/package/@stackbuilders/assertive-ts)
[![NPM license](https://img.shields.io/npm/l/@stackbuilders/assertive-ts)](./LICENSE)
[![GitHub Release Date](https://img.shields.io/github/release-date/stackbuilders/assertive-ts)](https://github.com/stackbuilders/assertive-ts/releases)
[![Snyk Vulnerabilities](https://img.shields.io/snyk/vulnerabilities/npm/@stackbuilders/assertive-ts)](https://snyk.io/)

# AssertiveTS

Expand Down Expand Up @@ -159,9 +164,53 @@ expect(bar)

You can find the full API reference [here](https://stackbuilders.github.io/assertive-ts/docs/build/)

## Coming Soon
## Extension mechanism ⚙️

- Extension mechanism ⚙️
This feature allows you to extend the `expect(..)` function to return additional `Assertion<T>` instances depending on the value under test. This opens the door to add additional assertion matchers for more specific cases. An `Assertion<T>` can be added in the form of a `Plugin`:
```ts
interface Plugin<T, A extends Assertion<T>> {
Assertion: new(actual: T) => A;
insertAt: "top" | "bottom";
predicate: (actual: unknown) => actual is T;
}
```

Where `Assertion` is the class you want to add, `insertAt` determines if the logic is inserted before or after all the primitives, and `predicate` is the logical code used to determine if value matches the `Assertion` type.

Once you have a plugin object, you can add it to assertive-ts with the `usePlugin(..)` helper function. Calls to this function should go on the setup file of your test runner or in a `beforeAll()` hook, so the extension is applied to all your tests.
```ts
// test/setup.ts
import { usePlugin } from "@stackbuilders/assertive-ts";

import { FilePlugin, HTMLElementPlugin } from "./plugins"; // your custom (or 3rd-party) plugins

usePlugin(FilePlugin);
usePlugin(HTMLElementPlugin);
// ...
```

### What about the types?

Each new plugin should add an additional overload to the `expect(..)` function to maintain type safety. To do that, you can extend the `Expect` interface to add the additional overloads. For example:
```ts
import { FileAssertion } from "./FileAssertion";
import { HTMLElementAssertion } from "./HTMLElementAssertion";

declare module "@stackbuilders/assertive-ts" {

export interface Expect {
(actual: File): FileAssertion;
(actual: HTMLElement): HTMLElementAssertion;
// ...
}
}
```

> **Note:** 3rd-party libraries should do this on their types entry point (index.d.ts), this way the interface is automatically extended when their plugin is passed to the `usePlugin(..)` function.
### How to...

If you're looking to write a plugin, you can find a simple example [here](./examples/symbolPlugin/). The example plugin is used in the [Jest](./examples/jest/test/plugins.test.ts) and [Mocha](./examples/mocha/test/plugins.test.ts) examples too, so you can also take a look at them to see how to apply and use plugins.

## Contributors ✨

Expand Down
5 changes: 0 additions & 5 deletions examples/jest/jest.config.js

This file was deleted.

10 changes: 10 additions & 0 deletions examples/jest/jest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { type Config } from "jest";

const jestConfig: Config = {
preset: "ts-jest",
setupFilesAfterEnv: ["./test/setup.ts"],
testEnvironment: "node",
testRegex: "test/.*\\.test\\.ts$"
};

export default jestConfig;
4 changes: 3 additions & 1 deletion examples/jest/package.json
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
{
"name": "@examples/jest",
"packageManager": "yarn@3.2.2",
"private": true,
"scripts": {
"compile": "tsc",
"test": "jest"
},
"devDependencies": {
"@examples/symbol-plugin": "workspace:^",
"@stackbuilders/assertive-ts": "workspace:^",
"@types/jest": "^29.0.0",
"@types/node": "^18.7.11",
"jest": "^29.0.3",
"ts-jest": "^29.0.0",
"ts-node": "^10.9.1",
"typescript": "^4.8.4"
}
}
File renamed without changes.
11 changes: 11 additions & 0 deletions examples/jest/test/plugins.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { expect } from "@stackbuilders/assertive-ts";

describe("plugins", () => {
it("can use the symbol plugin", () => {
const foo = Symbol("foo");

expect(foo)
.toBeSymbol()
.toHaveDescription("foo");
});
});
6 changes: 6 additions & 0 deletions examples/jest/test/setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { SymbolPlugin } from "@examples/symbol-plugin";
import { usePlugin } from "@stackbuilders/assertive-ts";

beforeAll(() => {
usePlugin(SymbolPlugin);
});
7 changes: 5 additions & 2 deletions examples/mocha/.mocharc.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
{
"extension": ["ts"],
"spec": "tests/**/*.test.ts",
"require": ["ts-node/register"]
"spec": "test/**/*.test.ts",
"require": [
"ts-node/register",
"test/hooks.ts"
]
}
2 changes: 1 addition & 1 deletion examples/mocha/package.json
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
{
"name": "@examples/mocha",
"packageManager": "yarn@3.2.2",
"private": true,
"scripts": {
"compile": "tsc",
"test": "mocha"
},
"devDependencies": {
"@examples/symbol-plugin": "workspace:^",
"@stackbuilders/assertive-ts": "workspace:^",
"@types/mocha": "^9.1.1",
"@types/node": "^18.7.18",
Expand Down
11 changes: 11 additions & 0 deletions examples/mocha/test/hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { SymbolPlugin } from "@examples/symbol-plugin";
import { usePlugin } from "@stackbuilders/assertive-ts";
import { RootHookObject } from "mocha";

export function mochaHooks(): RootHookObject {
return {
beforeAll() {
usePlugin(SymbolPlugin);
}
};
}
File renamed without changes.
11 changes: 11 additions & 0 deletions examples/mocha/test/plugins.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { expect } from "@stackbuilders/assertive-ts";

describe("plugins", () => {
it("can use the symbol plugin", () => {
const foo = Symbol("foo");

expect(foo)
.toBeSymbol()
.toHaveDescription("foo");
});
});
17 changes: 17 additions & 0 deletions examples/symbolPlugin/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"name": "@examples/symbol-plugin",
"private": true,
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsc -p tsconfig.prod.json",
"compile": "tsc"
},
"devDependencies": {
"@stackbuilders/assertive-ts": "workspace:^",
"typescript": "^4.8.4"
},
"peerDependencies": {
"@stackbuilders/assertive-ts": "*"
}
}
17 changes: 17 additions & 0 deletions examples/symbolPlugin/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Plugin } from "@stackbuilders/assertive-ts";

import { SymbolAssertion } from "./lib/SymbolAssertion";

declare module "@stackbuilders/assertive-ts" {

export interface Expect {
// tslint:disable-next-line: callable-types
(actual: symbol): SymbolAssertion;
}
}

export const SymbolPlugin: Plugin<symbol, SymbolAssertion> = {
Assertion: SymbolAssertion,
insertAt: "top",
predicate: (actual): actual is symbol => typeof actual === "symbol"
};
43 changes: 43 additions & 0 deletions examples/symbolPlugin/src/lib/SymbolAssertion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { Assertion, AssertionError } from "@stackbuilders/assertive-ts";

export class SymbolAssertion extends Assertion<symbol> {

public constructor(actual: symbol) {
super(actual);
}

public toBeSymbol(): this {
const error = new AssertionError({
actual: this.actual,
message: `Expected ${String(this.actual)} to be a symbol`
});
const invertedError = new AssertionError({
actual: this.actual,
message: `Expected ${String(this.actual)} not to be a symbol`
});

return this.execute({
assertWhen: typeof this.actual === "symbol",
error,
invertedError
});
}

public toHaveDescription(expected: string): this {
const error = new AssertionError({
actual: this.actual.description,
expected,
message: `Expected ${String(this.actual)} to have the description: ${expected}`
});
const invertedError = new AssertionError({
actual: this.actual.description,
message: `Expected ${String(this.actual)} not to have the description: ${expected}`
});

return this.execute({
assertWhen: this.actual.description === expected,
error,
invertedError
});
}
}
10 changes: 10 additions & 0 deletions examples/symbolPlugin/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./build",
},
"exclude": [
"build/*",
"dist/*"
]
}
8 changes: 8 additions & 0 deletions examples/symbolPlugin/tsconfig.prod.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"incremental": false,
"outDir": "./dist"
},
"include": ["src/**/*"]
}
1 change: 1 addition & 0 deletions examples/symbolPlugin/tsconfig.tsbuildinfo

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"compile:examples:jest": "turbo run compile --filter=@examples/jest",
"compile:examples:mocha": "turbo run compile --filter=@examples/mocha",
"lint": "tslint -c tslint.json \"**/*.ts\"",
"pages": "yarn workspace @stackbuilders/assertive-ts pages",
"release": "turbo release",
"test": "turbo run test",
"test:core": "turbo run test --filter=@stackbuilders/assertive-ts",
Expand Down
33 changes: 26 additions & 7 deletions package/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,30 @@
import { expect } from "./lib/expect";
import { Assertion } from "./lib/Assertion";
import { config, Plugin } from "./lib/config/Config";
import { expect, Expect } from "./lib/expect";

export { expect };
export { expect as assert };
export { expect as assertThat };
export { AssertionError } from "assert/strict";
export {
type AssertionFactory,
type StaticTypeFactories,
type TypeFactory,
AssertionFactory,
StaticTypeFactories,
TypeFactory,
TypeFactories
} from "./lib/helpers/TypeFactories";

export {
Assertion,
Expect,
Plugin,
expect,
expect as assert,
expect as assertThat
};

/**
* Extends `@stackbuilders/assertive-ts` with a local or 3rd-party plugin.
*
* @param plugin the plugin to use to extend assertive-ts
* @see {@link Plugin Plugin}
*/
export function usePlugin<T, A extends Assertion<T>>(plugin: Plugin<T, A>): void {
config.addPlugin(plugin);
}
42 changes: 42 additions & 0 deletions package/src/lib/config/Config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Assertion } from "../Assertion";

export interface Plugin<T, A extends Assertion<T>> {
/**
* The `Assertion<T>` instance the plugin adds
*/
Assertion: new(actual: T) => A;
/**
* The position were the predicate will test to return the `Assertion` or not:
* - `top`: Test before all primitives and object-related types.
* - `bottom`: Test after all primitives and object-related types.
*/
insertAt: "top" | "bottom";
/**
* The predicate that tests if the actual value should returnt and instance of
* the plugin's `Assertion`.
*/
predicate: (actual: unknown) => actual is T;
}

/**
* A configuration class used to share a `config` instance. Useful to expose
* methods that can change global settings.
*/
export class Config {

private pluginSet: Set<Plugin<any, Assertion<any>>>;

public constructor() {
this.pluginSet = new Set();
}

public plugins(): ReadonlyArray<Plugin<any, Assertion<any>>> {
return Array.from(this.pluginSet.values());
}

public addPlugin<T, A extends Assertion<T>>(plugin: Plugin<T, A>): void {
this.pluginSet.add(plugin);
}
}

export const config = new Config();
Loading

0 comments on commit 439eba3

Please sign in to comment.