qtests
Version:
Comprehensive Node.js testing framework with method stubbing, console mocking, environment management, and automatic stub resolution for axios, winston, and other modules
562 lines (431 loc) • 20.5 kB
Markdown
# qtests
A comprehensive Node.js testing framework with zero dependencies. Provides intelligent test generation, method stubbing, console mocking, and drop-in replacements for popular modules. **Now with ES Module and TypeScript support!**
🎉 **Latest Updates (September 2025)**:
- ✅ ESM + TypeScript Jest harness: runner always loads `config/jest.config.mjs` and passes `--passWithNoTests` for stable CI
- ✅ HTTP testing shim alignment: TS shim re-exports a working JS shim with chainable `.send()` and proper `req.body`
- ✅ Safe Mongoose mocking: Jest `moduleNameMapper` maps `mongoose` to qtests' manual mock (no real DB access)
- ✅ Performance optimized: Jest-like batch execution with 69% speed improvement
- ✅ Enhanced test generation: Smarter filtering, React-aware scaffolds, and safe defaults
## 🚀 Quick Start
```bash
npm install qtests --save-dev
```
qtests passively scaffolds its runner at your project root after install (via npm postinstall). No extra steps required.
**Configure your project for ES modules** by adding to `package.json`:
```json
{
"type": "module",
"main": "index.ts"
}
```
**TypeScript setup:**
```typescript
// Enable automatic stubbing
import './node_modules/qtests/setup.js';
// Your modules now use qtests stubs automatically
import axios from 'axios'; // Uses qtests stub
import winston from 'winston'; // Uses qtests stub
// Import with full type safety
import { stubMethod, mockConsole, testEnv, QtestsAPI } from 'qtests';
// Use with TypeScript intellisense
const restore = stubMethod(myObject, 'methodName', mockImplementation);
```
## ✨ Key Features
- **🤖 Intelligent Test Generation** - Automatically discovers and generates missing tests
- **🎭 Method Stubbing** - Temporarily replace object methods with automatic restoration
- **📺 Console Mocking** - Jest-compatible console spies with fallback for vanilla Node.js
- **🌍 Environment Management** - Safe backup and restore of environment variables
- **📦 Module Stubs** - Drop-in replacements for axios, winston, and other dependencies
- **🔌 Offline Mode** - Automatic stub resolution when external services are unavailable
- **🏃 Lightweight Test Runner** - Zero-dependency test execution engine
- **🌐 HTTP Testing** - Integration testing utilities (supertest alternative)
- **📧 Email Mocking** - Email testing without external mail services
- **🆕 ES Module Support** - Full compatibility with modern ES Module syntax
- **🔷 TypeScript Support** - Complete type definitions and intellisense
- **⚡ Zero Dependencies** - No production dependencies to bloat your project
## 🧩 Mock API (Runtime‑Safe)
qtests exposes a small, extensible mocking API that works at runtime without rewriting paths or adding heavy frameworks.
Defaults registered by setup:
- `axios` → qtests stub (truthy, no network)
- `winston` → qtests stub (no‑op logger with format/transports)
- `mongoose` → project `__mocks__/mongoose.js` if present, or a minimal safe object
Usage:
```ts
import qtests from 'qtests';
// Register a custom module mock
qtests.mock.module('external-service', () => ({
default: {
call: async () => ({ ok: true })
}
}));
// Now `require('external-service')` or `import ... from 'external-service'` returns the mock (CJS via require hook; ESM early via optional loader)
```
Notes:
- Activation is runtime‑safe: a single require hook returns registered mocks; previously loaded CJS modules are best‑effort evicted from `require.cache`.
- ESM projects can optionally use the loader for earliest interception:
- `node --loader=qtests/loader.mjs your-app.mjs`
- setup still runs first in Jest via `config/jest-setup.ts` so defaults are active before imports.
## 📖 Core Usage
### Method Stubbing
```typescript
import { stubMethod } from 'qtests';
const myObj = { greet: (name: string) => `Hello, ${name}!` };
// Stub the method
const restore = stubMethod(myObj, 'greet', () => 'Hi!');
console.log(myObj.greet('Brian')); // 'Hi!'
// Restore original
restore();
console.log(myObj.greet('Brian')); // 'Hello, Brian!'
```
### Console Mocking
```typescript
import { mockConsole } from 'qtests';
const spy = mockConsole('log');
console.log('test message');
console.log(spy.mock.calls); // [['test message']]
spy.mockRestore(); // Restore original console.log
```
### Environment Management
```typescript
import { testEnv } from 'qtests';
// Set test environment
testEnv.setTestEnv(); // Sets NODE_ENV=test, DEBUG=qtests:*
// Save and restore environment
const saved = testEnv.saveEnv();
process.env.TEST_VAR = 'modified';
testEnv.restoreEnv(saved); // TEST_VAR removed, original state restored
```
## 🧪 Unified Test Runner (API‑Only)
- One command for everyone: `npm test`.
- One runner: `qtests-runner.mjs` runs Jest via the programmatic API `runCLI` (no child processes, no `tsx`).
- Honors: `QTESTS_INBAND=1` (serial) and `QTESTS_FILE_WORKERS=<n>` (max workers).
- Always uses project config and `passWithNoTests`, with `cache=true` and `coverage=false`.
- Debugging: creates `DEBUG_TESTS.md` on failures; override with `QTESTS_DEBUG_FILE=path` or suppress with `QTESTS_SUPPRESS_DEBUG=1`.
Runner availability and generator behavior:
- Postinstall scaffolding automatically creates `qtests-runner.mjs` at the project root (INIT_CWD) when missing.
- `npx qtests-generate` ALWAYS (re)writes `qtests-runner.mjs` at the client root to keep the runner current.
- Scaffolds `config/jest.config.mjs` (ignores `dist/`, `build/`) and `config/jest-require-polyfill.cjs` (ensures `require(...)` is available in ESM tests).
- Scaffolds `qtests-runner.mjs` (API‑only runner).
- Ensures helper scripts exist: `scripts/clean-dist.mjs` and `scripts/ensure-runner.mjs`.
- Updates `package.json` scripts to:
- `pretest`: `node scripts/clean-dist.mjs && node scripts/ensure-runner.mjs`
- `test`: `node qtests-runner.mjs`
Stale runner protection:
- `scripts/ensure-runner.mjs` silently replaces stale runners (e.g., spawn/parallel-mode or missing API‑only invariants) with the validated template.
Migration (from spawn‑based runners):
- Run `npx qtests-generate` once to update the runner and scripts.
- Ensure package.json contains the `pretest` and `test` commands above.
- Remove any custom `tsx`/spawn‑based test commands.
CI verification:
- `npm run ci:verify` validates runner policy, script wiring, dist hygiene, and Jest config.
## 🤖 Automatic Test Generation
### CLI Usage (TypeScript ESM)
```bash
# Generate tests for entire project
npx qtests-generate
# Custom source directory
npx qtests-generate --src lib
# Custom source and test directories
npx qtests-generate --src app --test-dir tests/integration
# Only unit tests, preview without writing
npx qtests-generate --unit --dry-run
# Restrict to TypeScript files and skip existing tests
npx qtests-generate --include "**/*.ts" --exclude "**/*.test.ts"
# Use AST mode (requires typescript) and allow overwrites of generated tests
npx qtests-generate --mode ast --force
# Force React mode and add router wrapper
npx qtests-generate --react --with-router
# Backward-compatible alias
# (if your environment still references the old name)
npx qtests-ts-generate
```
### Programmatic Usage (TypeScript ESM)
```typescript
import { TestGenerator } from 'qtests';
const generator = new TestGenerator({
SRC_DIR: 'src',
TEST_DIR: 'tests/integration',
include: ['**/*.ts'],
exclude: ['**/*.test.ts'],
mode: 'heuristic', // or 'ast' (requires `typescript`), falls back gracefully
});
await generator.generateTestFiles(false); // pass true for dry-run
const results = generator.getResults();
console.log(`Generated ${results.length} test files`);
```
**Smart Discovery Features:**
- Walks entire project directory structure
- Supports feature-first projects (tests alongside source files)
- Detects existing tests to avoid duplicates
- Handles multiple project structures (traditional, monorepo, mixed)
## 🔌 Module Stubs
### Axios Stub
```typescript
// Automatic when using qtests/setup
import axios from 'axios';
const response = await axios.get('/api');
// Returns: { data: {}, status: 200, statusText: 'OK', headers: {}, config: {} }
await axios.post('/api', data); // Enhanced response format
```
### Winston Stub
```typescript
// Automatic when using qtests/setup
import winston from 'winston';
const logger = winston.createLogger();
logger.info('This produces no output'); // Silent
```
### Custom Module Stubs (Ad‑Hoc)
When you need to stub a niche dependency (beyond the built‑ins axios/winston) without changing qtests itself, register a custom stub in tests:
```ts
// Always load setup first so axios/winston are stubbed globally
import './node_modules/qtests/setup.js';
// Then register your ad‑hoc stub(s)
import { registerModuleStub } from 'qtests/utils/customStubs.js';
registerModuleStub('external-service-client', {
ping: () => 'pong',
get: async () => ({ ok: true })
});
// Now this resolves to your in‑memory stub even if the module is not installed
const client = require('external-service-client');
await client.get(); // { ok: true }
```
Notes:
- Call `registerModuleStub` BEFORE the first require/import of that module.
- Use `unregisterModuleStub(id)` and `clearAllModuleStubs()` for cleanup in afterEach.
- Honors `QTESTS_SILENT=1|true` to reduce noise in CI logs.
## 🏃 Lightweight Test Runner
```typescript
import { runTestSuite, createAssertions } from 'qtests';
const assert = createAssertions();
const tests = {
'basic test': () => {
assert.equals(1 + 1, 2);
assert.isTrue(true);
},
'async test': async () => {
const result = await Promise.resolve('done');
assert.equals(result, 'done');
}
};
runTestSuite('My Tests', tests);
```
## 🌐 HTTP Testing
```typescript
// For generated API tests, a local shim is scaffolded at:
// tests/generated-tests/utils/httpTest.ts (re-exports a JS shim)
// tests/generated-tests/utils/httpTest.shim.js (implementation with .send())
// You can also import the same helpers directly from qtests if preferred.
import { httpTest } from 'qtests/lib/envUtils.js';
// Create mock Express app
const app = httpTest.createMockApp();
app.get('/users', (req, res) => {
res.statusCode = 200;
res.end(JSON.stringify({ users: [] }));
});
// Test the app — chainable .send() supported; JSON is defaulted and parsed
const response = await httpTest.supertest(app)
.get('/users')
.expect(200)
.end();
```
## 📧 Email Testing
```typescript
import { sendEmail } from 'qtests/lib/envUtils.js';
// Mock email sending
const result = await sendEmail.send({
to: 'user@example.com',
subject: 'Welcome',
text: 'Welcome to our app!'
});
console.log(result.success); // true
console.log(sendEmail.getHistory()); // Array of sent emails
```
## 🛠️ Advanced Features
### Offline Mode
```typescript
import { offlineMode } from 'qtests';
// Enable offline mode
offlineMode.setOfflineMode(true);
// Get stubbed axios automatically
const axios = offlineMode.getAxios();
await axios.get('/api/data'); // Returns {} instead of real request
```
### Integration with Jest
```typescript
import { testHelpers } from 'qtests';
test('console output', async () => {
await testHelpers.withMockConsole('log', (spy) => {
console.log('test');
expect(spy.mock.calls[0][0]).toBe('test');
});
});
```
## 📚 API Reference
### Core Methods
| Method | Description |
|--------|-------------|
| `stubMethod(obj, methodName, replacement)` | Replace object method with stub |
| `mockConsole(method)` | Mock console methods with spy |
| `testEnv.setTestEnv()` | Set standard test environment |
| `testEnv.saveEnv()` / `restoreEnv()` | Backup/restore environment |
| `offlineMode.setOfflineMode(enabled)` | Toggle offline mode |
### Test Generation
| Method | Description |
|--------|-------------|
| `new TestGenerator(options)` | Create test generator instance |
| `generator.generateTestFiles(dryRun?)` | Generate missing tests (dryRun optional) |
| `generator.getResults()` | Get list of generated files |
| CLI: `npx qtests-generate` | Command-line test generation (alias: `qtests-ts-generate`) |
### Test Runner
| Method | Description |
|--------|-------------|
| `runTestSuite(name, tests)` | Execute test suite |
| `createAssertions()` | Get assertion methods |
## 🔷 TypeScript Configuration
To use qtests with ES modules and TypeScript, update your `package.json`:
```json
{
"type": "module",
"main": "index.ts"
}
```
And ensure your `tsconfig.json` supports ES modules:
```json
{
"compilerOptions": {
"target": "ES2020",
"module": "ES2020",
"moduleResolution": "node",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true
},
"ts-node": {
"esm": true
}
}
```
### Import Patterns
```typescript
// Core utilities with full type safety
import { stubMethod, mockConsole, testEnv, TestGenerator, QtestsAPI } from 'qtests';
// Advanced utilities
import { httpTest, sendEmail, testSuite } from 'qtests/lib/envUtils.js';
// Module stubs
import { stubs } from 'qtests';
await stubs.axios.get('https://example.com');
```
## 🎯 Best Practices
## 🧰 CLI Reference
### qtests-generate (alias: qtests-ts-generate)
- Usage: `qtests-generate [options]`
- Purpose: Scans source files and generates missing tests.
- Options:
- `-s, --src <dir>`: Source directory root to scan. Default: `.`
- `-t, --test-dir <dir>`: Directory for integration/API tests. Default: `tests/generated-tests`
- `--mode <heuristic|ast>`: Analysis mode. `ast` attempts TypeScript-based analysis if `typescript` is installed; falls back otherwise. Default: `heuristic`
- `--unit`: Generate only unit tests
- `--integration`: Generate only integration/API tests
- `--include <glob>`: Include only matching files (repeatable)
- `--exclude <glob>`: Exclude matching files (repeatable)
- `--dry-run`: Preview actions; no files written or package.json updates
- `--force`: Overwrite generated test files (filenames containing `.GeneratedTest` or legacy `.GenerateTest`)
- `--react`: Force React mode (use jsdom, React templates)
- `--with-router`: Wrap React tests with MemoryRouter when React Router is detected
- `--react-components`: Opt-in to generating tests for React components
- `--no-react-components`: Skip generating tests for React components (default)
- `-h, --help`: Show help
- `-v, --version`: Show version
Examples:
- `qtests-generate` — scan current directory with defaults
- `qtests-generate --src lib` — scan `lib` only
- `qtests-generate --unit --dry-run` — preview unit tests only
- `qtests-generate --include "**/*.ts" --exclude "**/*.test.ts"` — filter files
- `qtests-generate --mode ast --force` — AST mode and overwrite generated tests
Notes:
- On real runs (no `--dry-run`), the generator writes `config/jest.config.mjs`, `config/jest-setup.ts`, and creates `qtests-runner.mjs`.
- The generated runner includes `--config config/jest.config.mjs` and `--passWithNoTests`.
- The generated Jest config includes a `moduleNameMapper` for `mongoose` pointing to qtests' manual mock, preventing real DB access in unit tests.
- Update of `package.json` test script is now opt-in via `--update-pkg-script`.
- In `--dry-run`, none of the above files are written.
- Enhanced file filtering automatically skips demo/, examples/, config/, and test utility directories.
#### React/Hook Templates and Providers
- Components: By default, component test generation is disabled to reduce noise. Opt-in with `--react-components`.
When enabled, components get a smoke render via `React.createElement(Component, {})`, asserting container exists only.
- Hooks: Uses a probe component to mount the hook; avoids invalid direct calls.
- Providers: If `@tanstack/react-query` is imported, renders inside `QueryClientProvider`. If `react-hook-form` is detected (or `useFormContext`/`FormProvider` is referenced), wraps with `FormProvider` using `useForm()`.
- Optional Router: With `--with-router` and when source imports `react-router(-dom)`, wraps with `MemoryRouter`.
- Required-props fallback: If a component appears to require props (TS inline types or propTypes.isRequired), generator falls back to a safe existence test instead of rendering.
- Non-React modules: Emits safe existence checks or a module-load smoke test.
- Skipped directories: `__mocks__`, `__tests__`, `tests`, `test`, `generated-tests`, `manual-tests`, `node_modules`, `dist`, `build`, `.git`.
- API tests: Local `tests/generated-tests/utils/httpTest.ts` is scaffolded to re-export `httpTest.shim.js`, a minimal, dependency‑free HTTP test shim. Imports like `../utils/httpTest` resolve without extra project config. The shim supports `.send()` and exposes `req.body` to handlers.
#### File Extension Strategy & JSX
- Tests are emitted JSX-free using `React.createElement`, so unit/API tests default to `.ts`.
- `.tsx` is only chosen when the generated test includes JSX (rare; currently templates avoid JSX).
#### Safety + Sanity Filters
- Export filtering removes reserved/falsy/non-identifiers (e.g., `default`, `function`, `undefined`).
- If no safe export remains, the generator emits a module smoke test instead of bogus per-export tests.
- When valid React component/hook tests are emitted, the generator does not append generic “is defined” blocks.
### qtests runner
- Usage: `qtests-ts-runner`
- Purpose: Discovers and runs tests in the project with a Jest-first strategy.
- Behavior:
- Discovers files matching `.test|.spec|_test|_spec` with `.js|.ts|.jsx|.tsx`
- Tries `npx jest` with fast flags; falls back to verbose; finally runs with `node` if needed
- Runs tests in parallel batches (2x CPU cores, capped by file count)
- **Performance Optimized**: Jest-like batch execution achieving 69% speed improvement
- Notes:
- Automatically generated as `qtests-runner.mjs` by the test generator
- Always passes `--config config/jest.config.mjs` and `--passWithNoTests`
- Honors `QTESTS_SUPPRESS_DEBUG=1|true` to skip creating `DEBUG_TESTS.md`
- Honors `QTESTS_DEBUG_FILE` to set a custom debug report path/name
- Records Jest argv to `runner-jest-args.json` to aid debugging
- Works with TypeScript ESM projects via `ts-jest` (scaffolded by the generator)
### 1. Always Load Setup First
```typescript
// ✅ Correct
import './node_modules/qtests/setup.js';
import myModule from './myModule.js';
// ❌ Wrong
import myModule from './myModule.js';
import './node_modules/qtests/setup.js';
```
### 2. Clean Up After Tests
```typescript
test('example', () => {
const restore = stubMethod(obj, 'method', stub);
const spy = mockConsole('log');
// ... test code ...
// Always restore
restore();
spy.mockRestore();
});
```
### 3. Use Environment Helpers
```typescript
import { testHelpers } from 'qtests';
test('environment test', async () => {
await testHelpers.withSavedEnv(async () => {
process.env.TEST_VAR = 'value';
// Environment automatically restored
});
});
```
## 🐛 Troubleshooting
| Issue | Solution |
|-------|----------|
| Stubs not working (CommonJS) | Ensure `require('qtests/setup')` is called first |
| Stubs not working (ES Modules) | Ensure `import './node_modules/qtests/setup.js'` is called first |
| TypeScript import errors | Add `"type": "module"` to package.json and update tsconfig.json |
| ES Module syntax errors | Ensure `"module": "ES2020"` in tsconfig.json |
| Console pollution | Use `mockConsole()` to capture output |
| Environment leaks | Use `testHelpers.withSavedEnv()` for isolation |
| Module not found | Import advanced utilities from `qtests/lib/envUtils` |
| CLI not found | Use `npx qtests-generate` (alias: `qtests-ts-generate`) or install globally |
| File extension errors | Use `.js` extensions in ES module imports |
| Test generation creates tests for config files | Enhanced filtering now automatically skips demo/, examples/, config/, and test directories |
| generateKey returns empty string | Fixed in latest version - now correctly returns test keys like "test-api-key-user" |
| qtests-runner.mjs vs qtests-ts-runner.ts | Use `qtests-runner.mjs` as the generated runner; the CLI command remains `qtests-ts-runner` |
## 📄 License
MIT License - see LICENSE file for details.
## 🤝 Contributing
Contributions welcome! Please see our contributing guidelines and feel free to submit issues and pull requests.