Skip to content

Commit

Permalink
feat: support Vitest mocking
Browse files Browse the repository at this point in the history
  • Loading branch information
Tommy228 committed Dec 15, 2024
1 parent b27223f commit 468107b
Show file tree
Hide file tree
Showing 71 changed files with 3,883 additions and 25 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,6 @@ testem.log
# System Files
.DS_Store
Thumbs.db

# vitest
vite.config.mts.timestamp-*.mjs
35 changes: 34 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,15 @@ Spectator helps you get rid of all the boilerplate grunt work, leaving you with
- ✅ Easy DOM querying
- ✅ Clean API for triggering keyboard/mouse/touch events
- ✅ Testing `ng-content`
- ✅ Custom Jasmine/Jest Matchers (toHaveClass, toBeDisabled..)
- ✅ Custom Jasmine/Jest/Vitest Matchers (toHaveClass, toBeDisabled..)
- ✅ Routing testing support
- ✅ HTTP testing support
- ✅ Built-in support for entry components
- ✅ Built-in support for component providers
- ✅ Auto-mocking providers
- ✅ Strongly typed
- ✅ Jest Support
- ✅ Vitest Support


## Sponsoring ngneat
Expand Down Expand Up @@ -88,6 +89,7 @@ Become a bronze sponsor and get your logo on our README on GitHub.
- [Mocking OnInit Dependencies](#mocking-oninit-dependencies)
- [Mocking Constructor Dependencies](#mocking-constructor-dependencies)
- [Jest Support](#jest-support)
- [Vitest Support](#vitest-support)
- [Testing with HTTP](#testing-with-http)
- [Global Injections](#global-injections)
- [Component Providers](#component-providers)
Expand Down Expand Up @@ -1193,6 +1195,37 @@ When using the component schematic you can specify the `--jest` flag to have the
}
```

## Vitest Support
Like Jest, Spectator also supports Vitest. To use Vitest, import the functions from `@ngneat/spectator/vitest` instead of `@ngneat/spectator`.

```ts
import { createServiceFactory, SpectatorService } from '@ngneat/spectator/vitest';
import { AuthService } from './auth.service';
import { DateService } from './date.service';

describe('AuthService', () => {
let spectator: SpectatorService<AuthService>;
const createService = createServiceFactory({
service: AuthService,
mocks: [DateService]
});

beforeEach(() => spectator = createService());

it('should not be logged in', () => {
const dateService = spectator.inject<DateService>(DateService);
dateService.isExpired.mockReturnValue(true);
expect(spectator.service.isLoggedIn()).toBeFalsy();
});

it('should be logged in', () => {
const dateService = spectator.inject<DateService>(DateService);
dateService.isExpired.mockReturnValue(false);
expect(spectator.service.isLoggedIn()).toBeTruthy();
});
});
```

## Testing with HTTP
Spectator makes testing data services, which use the Angular HTTP module, a lot easier. For example, let's say that you have service with three methods, one performs a GET, one a POST and one performs
concurrent requests:
Expand Down
3 changes: 3 additions & 0 deletions angular.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@
"builder": "@angular-builders/jest:run",
"options": {}
},
"test-vitest": {
"builder": "@analogjs/vitest-angular:test"
},
"lint": {
"builder": "@angular-eslint/builder:lint",
"options": {
Expand Down
7 changes: 7 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"build:schematics": "tsc -p projects/spectator/schematics/tsconfig.json",
"test": "ng test",
"test:jest": "ng run spectator:test-jest",
"test:vitest": "ng run spectator:test-vitest",
"test:ci": "cross-env NODE_ENV=build yarn test && yarn test:jest --silent",
"lint": "ng lint",
"format": "prettier --write \"{projects,src}/**/*.ts\"",
Expand All @@ -30,6 +31,8 @@
"release:dry": "cd projects/spectator && standard-version --infile ../../CHANGELOG.md --dry-run"
},
"devDependencies": {
"@analogjs/vite-plugin-angular": "^1.10.1",
"@analogjs/vitest-angular": "^1.10.1",
"@angular-builders/jest": "^18.0.0",
"@angular-devkit/build-angular": "^19.0.1",
"@angular-devkit/schematics": "^19.0.1",
Expand All @@ -39,6 +42,7 @@
"@angular-eslint/schematics": "18.4.2",
"@angular-eslint/template-parser": "18.4.2",
"@angular/animations": "^19.0.0",
"@angular/build": "^19.0.0",
"@angular/cdk": "^19.0.0",
"@angular/cli": "^19.0.1",
"@angular/common": "^19.0.0",
Expand Down Expand Up @@ -70,6 +74,7 @@
"jasmine-spec-reporter": "7.0.0",
"jest": "29.7.0",
"jest-preset-angular": "14.1.0",
"jsdom": "^25.0.1",
"karma": "6.4.2",
"karma-chrome-launcher": "3.2.0",
"karma-coverage-istanbul-reporter": "3.0.3",
Expand All @@ -83,6 +88,8 @@
"ts-node": "10.1.0",
"tslib": "^2.6.2",
"typescript": "5.6.3",
"vite-tsconfig-paths": "^5.0.1",
"vitest": "2.1.8",
"zone.js": "0.15.0"
},
"config": {
Expand Down
25 changes: 25 additions & 0 deletions projects/spectator/setup-vitest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import '@analogjs/vitest-angular/setup-zone';

import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing';
import { getTestBed } from '@angular/core/testing';
import { defineGlobalsInjections } from '@ngneat/spectator';
import { TranslateService } from './test/translate.service';
import { TranslatePipe } from './test/translate.pipe';
import { vi } from 'vitest';

getTestBed().initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting());

defineGlobalsInjections({
providers: [TranslateService],
declarations: [TranslatePipe],
});

beforeEach(() => {
const mockIntersectionObserver = vi.fn();
mockIntersectionObserver.mockReturnValue({
observe: () => null,
unobserve: () => null,
disconnect: () => null,
});
window.IntersectionObserver = mockIntersectionObserver;
});
10 changes: 5 additions & 5 deletions projects/spectator/src/lib/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@ export function addMatchers(matchers: Record<string, CustomMatcherFactory>): voi
if (typeof jasmine !== 'undefined') {
jasmine.addMatchers(matchers);
} else {
// Jest isn't on the global scope when using ESM so we
// assume that it's Jest if Jasmine is not defined
const jestExpectExtend = {};
// Jest (when using ESM) and Vitest aren't on the global scope so we
// assume that it's Jest or Vitest if Jasmine is not defined
const jestVitestExpectExtend = {};
for (const key of Object.keys(matchers)) {
if (key.startsWith('to')) jestExpectExtend[key] = matchers[key]().compare;
if (key.startsWith('to')) jestVitestExpectExtend[key] = matchers[key]().compare;
}

(expect as any).extend(jestExpectExtend);
(expect as any).extend(jestVitestExpectExtend);
}
}
16 changes: 11 additions & 5 deletions projects/spectator/test/query-root/query-root.component.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Overlay, OverlayModule } from '@angular/cdk/overlay';
import { Overlay, OverlayModule, OverlayRef } from '@angular/cdk/overlay';
import { ComponentPortal } from '@angular/cdk/portal';
import { Component } from '@angular/core';
import { Component, OnDestroy } from '@angular/core';

@Component({
selector: 'app-query-root',
Expand Down Expand Up @@ -43,13 +43,19 @@ import { Component } from '@angular/core';
</div>
`,
})
export class QueryRootComponent {
export class QueryRootComponent implements OnDestroy {
public constructor(private overlay: Overlay) {}

private overlayRef?: OverlayRef;

public openOverlay(): void {
const componentPortal = new ComponentPortal(QueryRootOverlayComponent);
const overlayRef = this.overlay.create();
overlayRef.attach(componentPortal);
this.overlayRef = this.overlay.create();
this.overlayRef.attach(componentPortal);
}

public ngOnDestroy(): void {
this.overlayRef?.dispose();
}
}

Expand Down
13 changes: 9 additions & 4 deletions projects/spectator/tsconfig.spec.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,21 @@
"@ngneat/spectator/jest": [
"jest/src/public_api.ts"
],
"@ngneat/spectator/vitest": [
"vitest/src/public_api.ts"
],
"@ngneat/spectator/internals": [
"internals/src/public_api.ts"
]
}
},
"files": [
"test/test.ts"
"test/test.ts",
"setup-vitest.ts"
],
"include": [
"test/**/*.spec.ts",
"src/lib/matchers-types.ts"
]
"**/*.spec.ts",
"src/lib/matchers-types.ts",
],
"exclude": []
}
19 changes: 19 additions & 0 deletions projects/spectator/vite.config.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// <reference types="vitest" />
import { defineConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';

import angular from '@analogjs/vite-plugin-angular';

export default defineConfig(({ mode }) => ({
plugins: [angular(), tsconfigPaths()],
test: {
globals: true,
setupFiles: 'setup-vitest.ts',
environment: 'jsdom',
include: ['vitest/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
reporters: ['default'],
},
define: {
'import.meta.vitest': mode !== 'production',
},
}));
1 change: 1 addition & 0 deletions projects/spectator/vitest/ng-package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
1 change: 1 addition & 0 deletions projects/spectator/vitest/src/lib/dom-selectors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { byAltText, byLabel, byPlaceholder, byText, byTextContent, byTitle, byValue, byTestId, byRole } from '@ngneat/spectator';
64 changes: 64 additions & 0 deletions projects/spectator/vitest/src/lib/matchers-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
export * from 'vitest';

interface CustomMatchers<R = unknown> {
toExist(): R;

toHaveLength(expected: number): R;

toHaveId(id: string | number): R;

toHaveClass(className: string | string[], options?: { strict: boolean }): R;

toHaveAttribute(attr: string | object, val?: string): R;

toHaveProperty(prop: string | object, val?: string | boolean): R;

toContainProperty(prop: string | object, val?: string): R;

toHaveText(text: string | string[] | ((text: string) => boolean), exact?: boolean): R;

toContainText(text: string | string[] | ((text: string) => boolean), exact?: boolean): R;

toHaveExactText(text: string | string[] | ((text: string) => boolean), options?: { trim: boolean }): R;

toHaveExactTrimmedText(text: string | string[] | ((text: string) => boolean)): R;

toHaveValue(value: string | string[]): R;

toContainValue(value: string | string[]): R;

toHaveStyle(style: { [styleKey: string]: any }): R;

toHaveData({ data, val }: { data: string; val: string }): R;

toBeChecked(): R;

toBeIndeterminate(): R;

toBeDisabled(): R;

toBeEmpty(): R;

toBePartial(partial: object): R;

toBeHidden(): R;

toBeSelected(): R;

toBeVisible(): R;

toBeFocused(): R;

toBeMatchedBy(selector: string | Element): R;

toHaveDescendant(selector: string | Element): R;

toHaveDescendantWithText({ selector, text }: { selector: string; text: string }): R;

toHaveSelectedOptions(expected: string | string[] | HTMLOptionElement | HTMLOptionElement[]): R;
}

declare module 'vitest' {
interface Assertion<T = any> extends CustomMatchers<T> {}
interface AsymmetricMatchersContaining extends CustomMatchers {}
}
47 changes: 47 additions & 0 deletions projects/spectator/vitest/src/lib/mock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { FactoryProvider, AbstractType, Type } from '@angular/core';
import { installProtoMethods, CompatibleSpy, SpyObject as BaseSpyObject } from '@ngneat/spectator';
import { Mock, vi } from 'vitest';

export type SpyObject<T> = BaseSpyObject<T> & {
[P in keyof T]: T[P] & (T[P] extends (...args: any[]) => infer R ? (R extends (...args: any[]) => any ? Mock<R> : Mock<T[P]>) : T[P]);
};

/**
* @publicApi
*/
export function createSpyObject<T>(type: Type<T> | AbstractType<T>, template?: Partial<Record<keyof T, any>>): SpyObject<T> {
const mock: any = { ...template };

installProtoMethods(mock, type.prototype, () => {
const viFn = vi.fn();
const newSpy: CompatibleSpy = viFn as any;

newSpy.andCallFake = (fn: Function) => {
viFn.mockImplementation(fn as (...args: any[]) => any);

return newSpy;
};

newSpy.andReturn = (val: any) => {
viFn.mockReturnValue(val);
};

newSpy.reset = () => {
viFn.mockReset();
};

return newSpy;
});

return mock;
}

/**
* @publicApi
*/
export function mockProvider<T>(type: Type<T> | AbstractType<T>, properties?: Partial<Record<keyof T, any>>): FactoryProvider {
return {
provide: type,
useFactory: () => createSpyObject(type, properties),
};
}
Loading

0 comments on commit 468107b

Please sign in to comment.