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
12 changes: 12 additions & 0 deletions .changeset/stub-later-with-wins.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"type-plus": major
---

Let a later `stub.builder().with()` object win over an earlier initializer on the same key.

Before, a later `.with()` object only filled in keys the earlier initializers left unset, so `stub.builder({ a: 1 }).with({ a: 5 }).create()()` returned `{ a: 1 }`. It now returns `{ a: 5 }`. Objects are deep-merged, so nested keys a later object does not set are kept.

The partial stub passed to the created function still wins over every object initializer, and a function initializer still receives the stub so far and returns the next one.

Migration:
- If you relied on an earlier `.with()` value surviving a later one, reorder the `.with()` calls or drop the key from the later object.
90 changes: 82 additions & 8 deletions apps/website/src/content/docs/api/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -489,17 +489,91 @@ const user = stub<User>({ name: 'Alice' })
user.name // string
```

### stub.builder and stub.build
### stub.build

`stub.builder<T>(init)` returns a builder with `.with(init)` to layer on more partials or handler
functions, and `.create()` to produce the final stub factory. `stub.build<T>(init)` is the shorthand for
`stub.builder<T>(init).create()`.
```ts
stub.build<T>(init: RecursivePartial<T> | ((stub?: RecursivePartial<T>) => RecursivePartial<T>)): (stub?: RecursivePartial<T>) => T
```

Builds a stub factory for `T` from default values. Each call deep-merges the partial stub you pass over
`init`, so a value you pass wins over the default.

```ts
import { stub } from 'type-plus/testing'

type User = { id: string; name: string; profile: { email: string } }

const createUser = stub.build<User>({ id: '1', name: 'Alice', profile: { email: 'alice@example.com' } })

createUser() // { id: '1', name: 'Alice', profile: { email: 'alice@example.com' } }
createUser({ name: 'Bob' }) // { id: '1', name: 'Bob', profile: { email: 'alice@example.com' } }
```

`init` can also be a function. It receives the partial stub passed to the call, or `undefined`, and
returns the stub to use, which lets the defaults depend on what the caller passed. What the function
returns is used as is, so it decides which values win: here `...s` comes after the defaults.

```ts
const createUser = stub.build<User>((s) => ({
id: '1',
name: 'Alice',
...s,
profile: { email: `${s?.name ?? 'alice'}@example.com`, ...s?.profile },
}))

createUser({ name: 'bob' }) // { id: '1', name: 'bob', profile: { email: 'bob@example.com' } }
```

`stub.build<T>(init)` is the shorthand for `stub.builder<T>(init).create()`.

### stub.builder

```ts
stub.builder<T>(init: RecursivePartial<T> | ((stub?: RecursivePartial<T>) => RecursivePartial<T>))
```

Creates a builder for a stub factory of `T`, starting from `init`. The builder has two methods:

- `.with(init)` adds another partial stub or handler function and returns a new builder.
- `.create()` produces the stub factory, the same kind of function `stub.build` returns.

```ts
const s = stub.builder<{ a: number; b: string }>({ a: 1 }).with({ b: 'b' }).create()

s() // { a: 1, b: 'b' }
s({ a: 2 }) // { a: 2, b: 'b' }
```

The initializers run in order, starting from the partial stub passed to the call:

- An object initializer is deep-merged over the earlier ones, so a later `.with()` value wins. Nested
keys it does not set are kept.
- The partial stub passed to the call wins over every object initializer.
- A function initializer receives the stub so far: the partial stub passed to the call, merged with
the initializers before it. What it returns is the next stub, used as is.

```ts
const b = stub.builder<{ a: number; b: string }>({ a: 1 }).with({ b: 'b' }).create()
const s = stub.builder<{ a: number; b: string }>({ a: 1, b: 'b' }).with({ a: 2 }).create()

b({ a: 2 }) // { a: 2, b: 'b' }
s() // { a: 2, b: 'b' }
s({ a: 3 }) // { a: 3, b: 'b' }
```

Object initializers are deep-merged onto the accumulated stub; function initializers receive the
accumulated stub and return the next one.
A function initializer sees the values merged so far, including the partial stub passed to the call:

```ts
const s = stub.builder<{ a: number; b: number }>({ a: 1 }).with((acc) => ({ ...acc, b: acc!.a! * 10 })).create()

s() // { a: 1, b: 10 }
s({ a: 3 }) // { a: 3, b: 30 }
```

Each `.with()` returns a new builder and leaves the original unchanged, so one base builder can branch
into several factories.

```ts
const base = stub.builder<User>({ id: '1', profile: { email: 'user@example.com' } })

const createAlice = base.with({ name: 'Alice' }).create()
const createBob = base.with({ name: 'Bob' }).create()
```
13 changes: 13 additions & 0 deletions packages/type-plus/src/testing/stub.build.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,16 @@ it('pass stub input to the init function', () => {
const s = stub.build<{ a: number; b: string }>((stub) => ({ a: (stub?.a ?? 0) + 1 }))
expect(s({ a: 1 })).toEqual({ a: 2 })
})

it('lets the init function decide which values win', () => {
type User = { id: string; name: string; profile: { email: string } }
const createUser = stub.build<User>((s) => ({
id: '1',
name: 'Alice',
...s,
profile: { email: `${s?.name ?? 'alice'}@example.com`, ...s?.profile },
}))
const r = createUser({ name: 'bob' })
expect(r).toEqual({ id: '1', name: 'bob', profile: { email: 'bob@example.com' } })
testType.equal<typeof r, User>(true)
})
67 changes: 67 additions & 0 deletions packages/type-plus/src/testing/stub.builder.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,70 @@ it('can be use to create multiple builders', () => {
expect(b2({ c: 0 })).toEqual({ a: 1, b: 3, c: 0 })
expect(b2({ c: 1 })).toEqual({ a: 1, b: 3, c: 1 })
})

it('lets a later init object win over an earlier one', () => {
const s = stub.builder<{ a: number; b: string }>({ a: 1, b: 'b' }).with({ a: 2 }).create()
expect(s()).toEqual({ a: 2, b: 'b' })
expect(s({ a: 3 })).toEqual({ a: 3, b: 'b' })
})

it('deep-merges a later init object, keeping the nested keys it does not set', () => {
type S = { a: { x: number; y: number }; b: number }
const s = stub
.builder<S>({ a: { x: 1, y: 1 }, b: 1 })
.with({ a: { x: 2 } })
.with({ a: { y: 3 } })
.create()
expect(s()).toEqual({ a: { x: 2, y: 3 }, b: 1 })
expect(s({ a: { x: 9 } })).toEqual({ a: { x: 9, y: 3 }, b: 1 })
})

it('lets the stub passed to the created function win over every init object', () => {
const s = stub.builder<{ a: number; b: string }>({ a: 1 }).with({ a: 2 }).with({ a: 3, b: 'c' }).create()
expect(s({ a: 0 })).toEqual({ a: 0, b: 'c' })
})

it('passes the stub so far to an init function', () => {
const s = stub
.builder<{ a: number; b: number }>({ a: 1 })
.with((acc) => ({ ...acc, b: acc!.a! * 10 }))
.create()
expect(s()).toEqual({ a: 1, b: 10 })
expect(s({ a: 3 })).toEqual({ a: 3, b: 30 })
})

it('passes the later init object value to a following init function', () => {
const s = stub
.builder<{ a: number; b: number }>({ a: 1 })
.with({ a: 2 })
.with((acc) => ({ ...acc, b: acc!.a! * 10 }))
.create()
expect(s()).toEqual({ a: 2, b: 20 })
expect(s({ a: 3 })).toEqual({ a: 3, b: 30 })
})

it('keeps a value an init function returns when a later init object does not set it', () => {
const s = stub
.builder<{ a: number; b: number }>((acc) => ({ ...acc, a: (acc?.a ?? 0) + 1 }))
.with({ b: 2 })
.create()
expect(s({ a: 1 })).toEqual({ a: 2, b: 2 })
})

it('leaves the original builder unchanged when calling with()', () => {
const base = stub.builder<{ a: number; b: number }>({ a: 1, b: 1 })
const derived = base.with({ a: 2 })
expect(base.create()()).toEqual({ a: 1, b: 1 })
expect(derived.create()()).toEqual({ a: 2, b: 1 })
expect(derived).not.toBe(base)
})

it('does not mutate the init objects', () => {
const init = { a: { x: 1 } }
const s = stub
.builder<{ a: { x: number } }>(init)
.with({ a: { x: 2 } })
.create()
s({ a: { x: 3 } })
expect(init).toEqual({ a: { x: 1 } })
})
62 changes: 57 additions & 5 deletions packages/type-plus/src/testing/stub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,33 @@ function stubBuilder<T>(init: RecursivePartial<T> | ((stub?: RecursivePartial<T>
return builderInternal([init])
}

/**
* Drops from `init` every path that `stub` sets, so an init object never overrides the stub passed at call time.
*/
function withoutPathsOf(init: any, stub: any): any {
if (!isMergeable(stub)) return init
return Object.keys(init).reduce<Record<string, unknown>>((p, k) => {
const s = stub[k]
if (s === undefined) p[k] = init[k]
else if (isMergeable(s) && isMergeable(init[k])) p[k] = withoutPathsOf(init[k], s)
return p
}, {})
}

function isMergeable(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}

function builderInternal<T>(
initializers: Array<RecursivePartial<T> | ((stub?: RecursivePartial<T>) => RecursivePartial<T>)>,
) {
const builder = {
/**
* Adds an init object or handler to the builder.
*
* If `init` is an object, it will be merged with the stub object.
* If `init` is a function, it will be called with the stub object.
* If `init` is an object, it is deep-merged over the earlier initializers,
* except where the partial stub passed at call time sets a value.
* If `init` is a function, it is called with the stub so far and returns the next one.
*
* @return {Builder<T>} The builder instance.
*/
Expand All @@ -45,14 +63,13 @@ function builderInternal<T>(
/**
* Creates the resulting stub function.
*/

create() {
return (stub?: RecursivePartial<T>) => {
return initializers.reduce((acc, init) => {
if (typeof init === 'function') {
return init(acc)
}
return requiredDeep<RecursivePartial<T>>(init, acc)
return requiredDeep<RecursivePartial<T>>(acc ?? {}, withoutPathsOf(init, stub))
}, stub) as T
}
},
Expand All @@ -69,7 +86,8 @@ export declare namespace stub {
*
* Each call merges its own partial stub over `init`, so a value you pass in
* wins over the default. `init` can also be a function: it receives the
* partial stub passed to the call and returns the stub to use.
* partial stub passed to the call, or `undefined`, and returns the stub to use.
* What a function returns is used as is, so it decides which values win.
*
* It is `stub.builder(init).create()`.
*
Expand All @@ -78,6 +96,18 @@ export declare namespace stub {
* const s = stub.build<{ a: number; b: string }>({ b: 'b' })
* s({ a: 1 }) // { a: 1, b: 'b' }
* ```
*
* @example
* ```ts
* type User = { id: string; name: string; profile: { email: string } }
* const createUser = stub.build<User>((s) => ({
* id: '1',
* name: 'Alice',
* ...s,
* profile: { email: `${s?.name ?? 'alice'}@example.com`, ...s?.profile },
* }))
* createUser({ name: 'bob' }) // { id: '1', name: 'bob', profile: { email: 'bob@example.com' } }
* ```
*/
let build: typeof buildStub
/**
Expand All @@ -90,6 +120,14 @@ export declare namespace stub {
* - `.with(init)`: adds another partial stub or handler, applied after the previous ones.
* - `.create()`: creates the stub function.
*
* The initializers run in order, starting from the partial stub passed to the call:
*
* - An object initializer is deep-merged over the earlier ones, so a later `.with()` value wins.
* Nested keys it does not set are kept.
* - The partial stub passed to the call wins over every object initializer.
* - A function initializer receives the stub so far: the partial stub passed to the call,
* merged with the initializers before it. What it returns is the next stub, used as is.
*
* Each `.with()` returns a new builder, so one builder can branch into several.
*
* @example
Expand All @@ -98,6 +136,20 @@ export declare namespace stub {
* s() // { a: 1, b: 'b' }
* s({ a: 2 }) // { a: 2, b: 'b' }
* ```
*
* @example
* ```ts
* const s = stub.builder<{ a: number; b: string }>({ a: 1, b: 'b' }).with({ a: 2 }).create()
* s() // { a: 2, b: 'b' }
* s({ a: 3 }) // { a: 3, b: 'b' }
* ```
*
* @example
* ```ts
* const s = stub.builder<{ a: number; b: number }>({ a: 1 }).with((acc) => ({ ...acc, b: acc!.a! * 10 })).create()
* s() // { a: 1, b: 10 }
* s({ a: 3 }) // { a: 3, b: 30 }
* ```
*/
let builder: typeof stubBuilder
}
Expand Down
Loading