Skip to content
Open
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
58 changes: 58 additions & 0 deletions docs/next/ai/common-mistakes.md
Original file line number Diff line number Diff line change
Expand Up @@ -328,3 +328,61 @@ mock.expect.greet.called.withArg('alice') // separate arg check
```

**Why:** Negated count methods (`once()`, `twice()`, `times()`, `lt()`, `gt()`, etc.) are terminal — they return `void`. If chaining were allowed, each link would be negated independently (De Morgan), producing surprising results: `not.once()` passes but `not.withArg('alice')` fails even though the user intended "was not called exactly once with alice". Use two separate assertions instead.

## 15. Casting a `string[]` to silence the `stub<T>` name check

**❌ Wrong**

```typescript
stub<Database>(['query', 'findByID'] as (keyof Database)[])
// Compiles — and the mock has no findById. The cast suppressed the typo.
```

**✅ Right**

```typescript
stub<Database>(['query', 'findById'])
// Literal array — every name is checked against keyof Database
```

**Why:** `stub<T>(methodNames)` constrains names to `keyof T`, so a typo or a
renamed method is a compile error. Casting the literal array throws that away
and reinstates the exact bug the check exists to catch — the stub is missing
the method and you find out at runtime, if at all.

The cast is only correct when the list genuinely is not known statically:

```typescript
const names: string[] = readMethodsFromConfig()
stub<Database>(names as (keyof Database)[]) // nothing to check against
```

If you find yourself casting a literal, the name is wrong — fix the name.

## 16. Assuming a name list keeps up with the interface

**❌ Wrong**

```typescript
interface Gateway {
get(): Promise<Account>
save(patch: Patch): Promise<Account> // added later
}
const gw = stub<Gateway>(['get'])
await gw.save(patch) // typed fine — undefined at runtime
```

**✅ Right**

```typescript
const gw = stub(GatewayImpl) // surface derived from the class
```

**Why:** The name check verifies that the names you supplied *exist* on `T`. It
does not verify that you supplied *all* of them — partial stubs are a
deliberate, supported pattern. So a method added to the interface later is
still missing from the mock, while `Wrapped<T>` continues to claim it exists.

Deriving the surface from a class or a real instance removes the failure mode
entirely. Reach for a name list only when there is no class or instance to
derive from.
7 changes: 6 additions & 1 deletion docs/next/ai/decision-tree.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ Which API to reach for, by task. Tables, not prose. If your question isn't answe

| What you have | Factory | Example |
|---------------|---------|---------|
| A TypeScript interface or type, no instance | `stub<T>(['method1', 'method2'])` | `stub<Database>(['query'])` |
| A TypeScript interface or type, no instance | `stub<T>(['method1', 'method2'])` — names are checked against `keyof T` | `stub<Database>(['query'])` |
| A method name list built at runtime | `stub<T>(names as (keyof T)[])` | `stub<Database>(names as (keyof Database)[])` |
| An existing object instance | `stub(obj)` | `stub(new Logger())` |
| A class (want prototype methods auto-discovered) | `stub(MyClass)` | `stub(Greeter)` |
| A class (want static methods instead) | `stub(MyClass, undefined, { debug:{prefix:'deride',suffix:'stub'}, static:true })` | `stub(Greeter, undefined, {…, static:true})` |
Expand All @@ -16,6 +17,10 @@ Which API to reach for, by task. Tables, not prose. If your question isn't answe
| A brand-new standalone function from scratch | `func<F>()` | `func<(x: number) => number>()` |

**Rules of thumb:**
- Prefer `stub(MyClass)` or `stub(obj)` over a name list whenever a class or
instance is available — the surface is derived, so it cannot drift out of
sync when a method is added or renamed. A name list only checks the names you
wrote, not that you wrote all of them.
- `stub` replaces, `wrap` preserves real behaviour until overridden.
- `stub.class` is only for `new`-call interception. If you control the call site, inject a `stub(...)` instance instead.
- Use `func()` when the dependency is **itself** a function (callback, handler, fetcher).
Expand Down
26 changes: 21 additions & 5 deletions docs/next/api/stub.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,25 +6,41 @@ Build a test double from method names, an object, or a class.

```typescript
stub<I>(cls: new (...args: never[]) => I): Wrapped<I>
stub<T>(obj: T): Wrapped<T>
stub<T>(methodNames: (keyof T)[]): Wrapped<T>
stub<T>(
methodNames: string[],
stub<T = Record<string, unknown>>(
methodNames: readonly (keyof T)[],
properties?: { name: PropertyKey; options: PropertyDescriptor }[],
options?: StubOptions
): Wrapped<T>
stub<T>(obj: T): Wrapped<T>
```

::: warning Changed in v3
The method-name list is now constrained to `keyof T`. A typo or a stale name is
a compile error instead of a mock that is silently missing the method. See
[Migrating](../guide/migrating#method-name-lists-are-checked-against-keyof-t).
:::

## Parameters

### `target` (first arg)

Can be:

- **Array of method names** — TypeScript inference drives typing via `stub<T>([...])`.
- **Array of method names** — every name must be a key of `T`. With no explicit
`T`, the names are inferred into the mock's shape, so `stub(['query'])` gives
you `setup.query` rather than a mock typed as `string[]`. `readonly` and
`as const` arrays are accepted.
- **Existing object** — all own + inherited function-typed keys become stubbed methods.
- **Class constructor** — walks the prototype chain (not statics, unless `{ static: true }`).

Names assembled at runtime widen to `string[]` and cannot be checked against
`keyof T`. Assert the intent explicitly at the call site:

```typescript
const names: string[] = readMethodsFromSomewhere()
stub<Database>(names as (keyof Database)[])
```

### `properties` (optional second arg)

Array of `{ name, options }` descriptors to attach non-method fields. Useful for interfaces that mix methods and data:
Expand Down
39 changes: 38 additions & 1 deletion docs/next/guide/creating-mocks.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,44 @@ const mockDb = stub<Database>(['query', 'findById'])
mockDb.setup.query.toResolveWith([])
```

Methods default to returning `undefined` until configured. Every method on `T` must appear in the array; TypeScript checks it.
Methods default to returning `undefined` until configured.

Every name is checked against `keyof T`, so a typo or a renamed method is a
compile error:

```typescript
stub<Database>(['query', 'findByID'])
// Type '"findByID"' is not assignable to type 'keyof Database'.
// Did you mean '"findById"'?
```

The check is that each name you supply *exists* on `T` — not that you supplied
all of them. Listing a subset is fine and often what you want:

```typescript
const mockDb = stub<Database>(['query']) // findById intentionally omitted
```

The flip side is that a method added to `Database` later is silently missing
from the mock, while the type still claims it exists. Where a class or a real
instance is available, prefer [`stub(MyClass)`](#stub-from-a-class) or
[`stub(obj)`](#stub-from-an-existing-object) — those derive the surface, so it
cannot drift.

A list built at runtime widens to `string[]` and cannot be checked. Assert it
explicitly:

```typescript
const names: string[] = readMethodsFromConfig()
stub<Database>(names as (keyof Database)[])
```

With no type argument at all, the names are inferred into the mock's shape:

```typescript
const mock = stub(['query', 'close'])
mock.setup.query.toReturn('rows') // type-checks
```

### With extra properties

Expand Down
47 changes: 47 additions & 0 deletions docs/next/guide/migrating.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,53 @@

Common patterns from other mocking libraries, translated to deride.

## Upgrading from v2 to v3

### Method name lists are checked against `keyof T`

`stub<T>(methodNames)` now constrains every name to `keyof T`. Previously a
permissive `string[]` overload accepted anything, so a typo compiled silently
and produced a mock missing that method — the failure only surfaced at runtime,
when something called it.

```typescript
interface AccountGateway {
get(): Promise<Account>
save(patch: AccountPatch): Promise<Account>
}

stub<AccountGateway>(['get', 'sve'])
// v2: compiles. The stub has no save().
// v3: Type '"sve"' is not assignable to type 'keyof AccountGateway'.
// Did you mean '"save"'?
```

Correct name lists are unaffected. Two things changed that may need action:

**Name lists built at runtime.** A value typed `string[]` can no longer be
checked, so it is rejected. Assert the intent at the call site:

```typescript
const names: string[] = readMethodsFromConfig()

stub<Database>(names) // v3: compile error
stub<Database>(names as (keyof Database)[]) // explicit opt-out
```

Only do this when the list genuinely is not known statically. Casting a literal
array reinstates the bug this change exists to catch.

**Mocks built without a type argument.** `stub(['query', 'close'])` used to
resolve to `Wrapped<string[]>`, so the facades keyed off `Array` members —
`setup.length`, `setup.push` — instead of your method names. It now infers the
names into the mock's shape, so `setup.query` and `expect.close` type-check.
This is a fix, but it will surface real type errors in code that was relying on
the old, wrong inference.

`readonly` and `as const` name lists are now accepted, which previously failed.

Nothing about runtime behaviour changed — this is a type-level change only.

## From sinon

### `sinon.stub`
Expand Down
21 changes: 21 additions & 0 deletions docs/next/guide/typescript.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,27 @@ The type `Wrapped<Service>` is inferred automatically. You can annotate explicit
const mock: Wrapped<Service> = stub<Service>(['fetch', 'process'])
```

The name list is constrained to `keyof Service`, so the compiler catches a
mistyped or stale method name — including suggesting the intended one:

```typescript
stub<Service>(['fetch', 'proccess'])
// Type '"proccess"' is not assignable to type 'keyof Service'.
// Did you mean '"process"'?
```

Names produced at runtime widen to `string[]` and cannot be checked against
`keyof Service`; `stub<Service>(names as (keyof Service)[])` opts out
explicitly. See [Common mistakes](../ai/common-mistakes) for when that cast is
and isn't appropriate.

Omitting the type argument infers the mock's shape from the names themselves:

```typescript
const mock = stub(['fetch', 'process'])
mock.setup.fetch.toReturn('response') // ✓ — keys come from the name list
```

## `toResolveWith` unwraps `Promise<T>`

For async methods, `toResolveWith(v)` expects the resolved type — not the Promise itself:
Expand Down
31 changes: 21 additions & 10 deletions src/stub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ function staticMethods(ctor: AnyCtor): string[] {
return out
}

function isMethodNameArray(target: unknown): target is (string | PropertyKey)[] {
function isMethodNameArray(target: unknown): target is readonly PropertyKey[] {
return Array.isArray(target) && target.every((v) => typeof v === 'string' || typeof v === 'symbol' || typeof v === 'number')
}

Expand All @@ -81,24 +81,35 @@ function isMethodNameArray(target: unknown): target is (string | PropertyKey)[]
*
* Overloads:
* - `stub(MyClass)` — auto-discovers methods from the class prototype chain.
* - `stub<T>(['method1', 'method2'], properties?, options?)` — explicit list of
* method names, constrained to `keyof T` when `T` is supplied. `properties`
* adds property descriptors; `options` takes e.g. `{ static: true }` to mock
* a class's static side.
* - `stub(existingObject)` — auto-discovers methods from an instance.
* - `stub<T>(['method1', 'method2'])` — explicit list of method names.
* - `stub<T>(methodNames, properties, options)` — with property descriptors and
* additional options (e.g. `{ static: true }` to mock a class's static side).
*
* Pass `{ static: true }` alongside a class target to stub the static methods
* instead of the prototype methods.
*
* @remarks
* The name list is checked against `keyof T`, so a typo or a renamed method is
* a compile error rather than a mock that is silently missing the method. Names
* built at runtime are widened to `string[]` and cannot be checked — assert the
* intent explicitly with `stub<T>(names as (keyof T)[])`.
*
* The method-name overload deliberately precedes the object overload: an array
* is also an `object`, so were the order reversed `stub(['a', 'b'])` would bind
* `T` to `string[]` and key the facades off `Array` members instead of the
* supplied names.
*/
export function stub<I extends object>(cls: new (...args: never[]) => I): Wrapped<I>
export function stub<T extends object>(obj: T): Wrapped<T>
export function stub<T extends object>(methodNames: (keyof T)[]): Wrapped<T>
export function stub<T extends object>(
methodNames: string[],
export function stub<T extends object = Record<string, unknown>>(
methodNames: readonly (keyof T)[],
properties?: StubPropertyDescriptor[],
options?: StubOptions
): Wrapped<T>
export function stub<T extends object>(obj: T): Wrapped<T>
export function stub<T extends object>(
target: T | (keyof T)[] | string[] | AnyCtor,
target: T | readonly (keyof T)[] | readonly string[] | AnyCtor,
properties?: StubPropertyDescriptor[],
options: StubOptions = { debug: { prefix: PREFIX, suffix: 'stub' } }
): Wrapped<T> {
Expand Down Expand Up @@ -155,7 +166,7 @@ stub.class = function stubClass<C extends AnyCtor>(
{
construct(_target, args) {
constructorMock.invoke(args, undefined)
const instance = stub<InstanceType<C>>(methodNames as string[]) as Wrapped<InstanceType<C>>
const instance = stub<InstanceType<C>>(methodNames as (keyof InstanceType<C>)[]) as Wrapped<InstanceType<C>>
instances.push(instance)
for (const s of setups) s(instance)
return instance as unknown as object
Expand Down
37 changes: 37 additions & 0 deletions test/stub.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,3 +104,40 @@ describe('stub.class<typeof C>()', () => {
expect(MockedGreeter.instances).toEqual([a, b])
})
})

describe('stub<T>(methodNames) — name list forms (issue #127)', () => {
interface Db {
query(sql: string): string
close(): void
}

it('stubs every listed method', () => {
const db = stub<Db>(['query', 'close'])
db.setup.query.toReturn('rows')
expect(db.query('select 1')).toBe('rows')
db.close()
db.expect.close.called.once()
})

it('accepts a readonly / as const name list', () => {
const names = ['query', 'close'] as const
const db = stub<Db>(names)
db.setup.query.toReturn('rows')
expect(db.query('select 1')).toBe('rows')
})

it('only stubs the names listed — omitted methods are absent', () => {
const db = stub<Db>(['query'])
expect(typeof db.query).toBe('function')
expect((db as unknown as Record<string, unknown>).close).toBeUndefined()
})

it('infers the mock shape from the names when no type argument is given', () => {
const mock = stub(['query', 'close'])
mock.setup.query.toReturn('rows')
expect(mock.query()).toBe('rows')
// The array overload must win over the object overload, otherwise the
// facades key off Array members rather than the supplied names.
expect((mock.setup as unknown as Record<string, unknown>).push).toBeUndefined()
})
})
Loading