Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,54 @@ For example, import Rstest APIs without adding `@rstest/core` as a direct depend
import { expect, test } from 'rstack/test';
```

## Test projects

`define.test()` accepts an Rstest configuration. For a single project, Rstack automatically applies the `define.app()` configuration, or falls back to `define.lib()`:

```ts
import { define } from 'rstack';

define.app({
// Rsbuild configuration
});

define.test({
testEnvironment: 'happy-dom',
});
```

For multiple test environments in the same app or library, use inline projects:

```ts
import { define } from 'rstack';
import { defineInlineProject } from 'rstack/test';

define.app({
// Shared by all inline projects
});

define.test({
projects: [
defineInlineProject({
name: 'node',
include: ['./tests/node/**/*.test.ts'],
testEnvironment: 'node',
}),
defineInlineProject({
name: 'dom',
include: ['./tests/dom/**/*.test.tsx'],
testEnvironment: 'happy-dom',
}),
],
});
```

Rstack applies the shared app or library adapter to every inline project without an explicit `extends`. String project entries are passed to Rstest unchanged and load their own configuration.

Run all projects with `rs test`, or select one with `rs test --project dom`.

See [`examples/rstest-inline-projects`](./examples/rstest-inline-projects) for a complete React SSR example.

## Credits

Rstack CLI is inspired by:
Expand Down
2 changes: 1 addition & 1 deletion examples/documentation/docs/api/_meta.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
["index", "commands"]
["index", "commands", "testing"]
105 changes: 105 additions & 0 deletions examples/documentation/docs/api/testing.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# Testing

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will move ths page to Rstack CLI website in the future


Rstack uses [Rstest](https://rstest.rs/) for testing. The value passed to `define.test()` is an Rstest configuration, similar to using Rstest's `defineConfig()` directly.

Test APIs and configuration helpers are available from `rstack/test`:

```ts
import { defineInlineProject, expect, test } from 'rstack/test';
```

## Single project

For a single test project, pass the Rstest options directly:

```ts title="rstack.config.ts"
import { define } from 'rstack';

define.app({
// Rsbuild configuration
});

define.test({
testEnvironment: 'happy-dom',
setupFiles: ['./tests/rstest.setup.ts'],
});
```

Unless `extends` is provided explicitly, Rstack automatically extends the test configuration from `define.app()`. If there is no app configuration, it falls back to `define.lib()`.

## Inline projects

Use Rstest inline projects when one app or library needs multiple test configurations, such as separate Node.js and DOM test environments:

```ts title="rstack.config.ts"
import { define } from 'rstack';
import { defineInlineProject } from 'rstack/test';

define.app({
// Shared by both inline projects
});

define.test({
coverage: {
enabled: true,
},
projects: [
defineInlineProject({
name: 'node',
include: ['./tests/node/**/*.test.ts'],
testEnvironment: 'node',
}),
defineInlineProject({
name: 'dom',
include: ['./tests/dom/**/*.test.tsx'],
testEnvironment: 'happy-dom',
setupFiles: ['./tests/rstest.setup.ts'],
}),
],
});
```

Rstack applies the shared `define.app()` or `define.lib()` adapter to every inline project that does not define its own `extends`. Function-based app or library configuration is resolved once and shared by all inline projects.

Run all projects:

```bash
rs test
```

Run one project by name:

```bash
rs test --project dom
```

See [`examples/rstest-inline-projects`](https://github.com/rstackjs/rstack-cli/tree/main/examples/rstest-inline-projects) for a complete example that tests React server rendering in Node.js and client rendering in happy-dom.

## Custom `extends`

An inline project can opt out of automatic app or library inheritance by providing `extends` explicitly:

```ts
import { defineInlineProject } from 'rstack/test';

const customProject = defineInlineProject({
name: 'custom',
extends: customAdapter(),
});
```

If the root `define.test()` configuration provides `extends`, Rstack leaves the complete test configuration unchanged.

## External projects

String project entries are passed to Rstest unchanged:

```ts
import { define } from 'rstack';

define.test({
projects: ['./legacy/rstest.config.ts'],
});
```

External string projects load their own Rstest configuration and do not inherit the current `define.app()` or `define.lib()` configuration. Use inline projects when projects should share the current Rstack build configuration.
26 changes: 26 additions & 0 deletions examples/rstest-inline-projects/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"name": "@examples/rstest-inline-projects",
"private": true,
"type": "module",
"scripts": {
"build": "rs build",
"dev": "rs dev",
"preview": "rs preview",
"test": "rs test"
},
"dependencies": {
"react": "catalog:",
"react-dom": "catalog:"
},
"devDependencies": {
"@rsbuild/plugin-react": "catalog:",
"@testing-library/dom": "catalog:",
"@testing-library/react": "catalog:",
"@types/node": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"happy-dom": "catalog:",
"rstack": "workspace:*",
"typescript": "catalog:"
}
}
25 changes: 25 additions & 0 deletions examples/rstest-inline-projects/rstack.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { define } from 'rstack';
import { defineInlineProject } from 'rstack/test';

define.app(async () => {
const { pluginReact } = await import('@rsbuild/plugin-react');

return {
plugins: [pluginReact()],
};
});

define.test({
projects: [
defineInlineProject({
name: 'ssr',
include: ['./tests/ssr.test.tsx'],
testEnvironment: 'node',
}),
defineInlineProject({
name: 'dom',
include: ['./tests/dom.test.tsx'],
testEnvironment: 'happy-dom',
}),
],
});
8 changes: 8 additions & 0 deletions examples/rstest-inline-projects/src/App.tsx
Comment thread
chenjiahan marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export default function App() {
return (
<main>
<h1>Rstack React SSR</h1>
<p>Rendered on the server and hydrated in the browser.</p>
</main>
);
}
8 changes: 8 additions & 0 deletions examples/rstest-inline-projects/src/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { hydrateRoot } from 'react-dom/client';
import App from './App';

const root = document.getElementById('root');

if (root) {
hydrateRoot(root, <App />);
}
9 changes: 9 additions & 0 deletions examples/rstest-inline-projects/tests/dom.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { expect, test } from 'rstack/test';
import { render, screen } from '@testing-library/react';
import App from '../src/App';

test('renders the app in a DOM environment', () => {
render(<App />);

expect(screen.getByRole('heading', { name: 'Rstack React SSR' })).toBeTruthy();
});
10 changes: 10 additions & 0 deletions examples/rstest-inline-projects/tests/ssr.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { expect, test } from 'rstack/test';
import { renderToString } from 'react-dom/server';
import App from '../src/App';

test('renders the app on the server', () => {
const html = renderToString(<App />);

expect(html).toContain('Rstack React SSR');
expect(html).toContain('Rendered on the server');
});
17 changes: 17 additions & 0 deletions examples/rstest-inline-projects/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"lib": ["DOM", "ES2023"],
"jsx": "react-jsx",
"target": "ES2023",
"noEmit": true,
"skipLibCheck": true,
"types": ["rstack/types", "node"],
"moduleDetection": "force",
"moduleResolution": "bundler",
"verbatimModuleSyntax": true,
"resolveJsonModule": true,
"allowImportingTsExtensions": true,
"strict": true
},
"include": ["src", "tests", "rstack.config.ts"]
}
3 changes: 2 additions & 1 deletion packages/rstack/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ type Define = {
* This config is used by the `rs test` command.
*
* Unless `extends` is set explicitly, Rstest automatically extends `define.app` or
* falls back to `define.lib`. The app config takes precedence when both are defined.
* falls back to `define.lib`. For multi-project configs, this applies to every inline
* project without an explicit `extends`. The app config takes precedence when both are defined.
*/
test: (config: RstestConfigExport) => void;
/**
Expand Down
70 changes: 52 additions & 18 deletions packages/rstack/src/rstestConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,10 @@ import type { ConfigParams } from '@rsbuild/core';
import type { RstestConfig, RstestConfigExport } from '@rstest/core';
import { loadRstackConfig, type Configs } from './config.js';

const extendsConfig = async (configs: Configs, testConfig: RstestConfig, params: ConfigParams) => {
if ('extends' in testConfig) {
return testConfig;
}

const resolveAutomaticExtends = async (
configs: Configs,
params: ConfigParams,
): Promise<RstestConfig['extends'] | undefined> => {
// Prefer the app when both app and lib are defined. Merging both adapters can
// introduce conflicting runtime, resolve, and source transform settings.
const appConfig = configs.app;
Expand All @@ -17,12 +16,9 @@ const extendsConfig = async (configs: Configs, testConfig: RstestConfig, params:
);
const config = typeof appConfig === 'function' ? await appConfig(params) : appConfig;

return {
...testConfig,
extends: withRsbuildConfig({
config,
}),
};
return withRsbuildConfig({
config,
});
}

const libConfig = configs.lib;
Expand All @@ -33,15 +29,53 @@ const extendsConfig = async (configs: Configs, testConfig: RstestConfig, params:
);
const config = typeof libConfig === 'function' ? await libConfig(params) : libConfig;

return {
...testConfig,
extends: withRslibConfig({
config,
}),
};
return withRslibConfig({
config,
});
}

return testConfig;
return undefined;
};

const injectExtends = <T extends RstestConfig>(
config: T,
automaticExtends: RstestConfig['extends'],
): T => {
if (!automaticExtends || 'extends' in config) {
return config;
}

return {
...config,
extends: automaticExtends,
};
};

const extendsConfig = async (configs: Configs, testConfig: RstestConfig, params: ConfigParams) => {
if ('extends' in testConfig) {
return testConfig;
}

if (testConfig.projects === undefined) {
const automaticExtends = await resolveAutomaticExtends(configs, params);
return injectExtends(testConfig, automaticExtends);
}

const shouldInjectProject = testConfig.projects.some(
(project) => typeof project !== 'string' && !('extends' in project),
);
if (!shouldInjectProject) {
return testConfig;
}

const automaticExtends = await resolveAutomaticExtends(configs, params);

return {
...testConfig,
projects: testConfig.projects.map((project) =>
typeof project === 'string' ? project : injectExtends(project, automaticExtends),
),
};
};

const resolveRstestConfig = async (configs: Configs) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { expect, test } from 'rstack/test';

declare const RSTACK_INHERITED_CONFIG: string;

test('an inline project can provide its own extends config', () => {
expect(RSTACK_INHERITED_CONFIG).toBe('custom');
});
9 changes: 9 additions & 0 deletions packages/rstack/test/config/define-test-projects-app/first.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { expect, test } from 'rstack/test';

declare const RSTACK_APP_CONFIG_CALLS: number;
declare const RSTACK_INHERITED_CONFIG: string;

test('the first inline project inherits the app config', () => {
expect(RSTACK_INHERITED_CONFIG).toBe('app');
expect(RSTACK_APP_CONFIG_CALLS).toBe(1);
});
Loading