Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add mock call history to access request configuration in test #4029

Open
wants to merge 22 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 10 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
124 changes: 124 additions & 0 deletions docs/docs/api/MockAgent.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,9 @@ for await (const data of result2.body) {
console.log('data', data.toString('utf8')) // data hello
}
```

#### Example - Mock different requests within the same file

```js
const { MockAgent, setGlobalDispatcher } = require('undici');
const agent = new MockAgent();
Expand Down Expand Up @@ -540,3 +542,125 @@ agent.assertNoPendingInterceptors()
// │ 0 │ 'GET' │ 'https://example.com' │ '/' │ 200 │ '❌' │ 0 │ 1 │
// └─────────┴────────┴───────────────────────┴──────┴─────────────┴────────────┴─────────────┴───────────┘
```

#### Example - access call history on MockAgent

By default, every call made within a MockAgent have their request configuration historied

```js
import { MockAgent, setGlobalDispatcher, request } from 'undici'

const mockAgent = new MockAgent()
setGlobalDispatcher(mockAgent)

await request('http://example.com', { query: { item: 1 }})

mockAgent.getCallHistory().firstCall()
// Returns
// MockCallHistoryLog {
// body: undefined,
// headers: undefined,
// method: 'GET',
// origin: 'http://example.com',
// fullUrl: 'http://example.com/?item=1',
// path: '/',
// searchParams: { item: '1' },
// protocol: 'http:',
// host: 'example.com',
// port: ''
// }
```

#### Example - access call history on intercepted client

You can use `registerCallHistory` to register a specific MockCallHistory instance while you are intercepting request. This is useful to have an history already filtered. Note that `getCallHistory()` will still register every request configuration.

```js
import { MockAgent, setGlobalDispatcher, request } from 'undici'

const mockAgent = new MockAgent()
setGlobalDispatcher(mockAgent)

const client = mockAgent.get('http://example.com')

client.intercept({ path: '/', method: 'GET' }).reply(200, 'hi !').registerCallHistory('my-specific-history-name')

await request('http://example.com') // intercepted
await request('http://example.com', { method: 'POST', body: JSON.stringify({ data: 'hello' }), headers: { 'content-type': 'application/json' }})

mockAgent.getCallHistory('my-specific-history-name').calls()
// Returns [
// MockCallHistoryLog {
// body: undefined,
// headers: undefined,
// method: 'GET',
// origin: 'http://example.com',
// fullUrl: 'http://example.com/',
// path: '/',
// searchParams: {},
// protocol: 'http:',
// host: 'example.com',
// port: ''
// }
// ]

mockAgent.getCallHistory().calls()
// Returns [
// MockCallHistoryLog {
// body: undefined,
// headers: undefined,
// method: 'GET',
// origin: 'http://example.com',
// fullUrl: 'http://example.com/',
// path: '/',
// searchParams: {},
// protocol: 'http:',
// host: 'example.com',
// port: ''
// },
// MockCallHistoryLog {
// body: "{ "data": "hello" }",
// headers: { 'content-type': 'application/json' },
// method: 'POST',
// origin: 'http://example.com',
// fullUrl: 'http://example.com/',
// path: '/',
// searchParams: {},
// protocol: 'http:',
// host: 'example.com',
// port: ''
// }
// ]
```

#### Example - clear call history

Clear all call history registered :

```js
const mockAgent = new MockAgent()

mockAgent.clearAllCallHistory()
```

Clear only one call history :

```js
const mockAgent = new MockAgent()

mockAgent.getCallHistory().clear()
mockAgent.getCallHistory('my-history')?.clear()
```

#### Example - call history instance class method

```js
const mockAgent = new MockAgent()

const mockAgentHistory = mockAgent.getCallHistory()

mockAgentHistory.calls() // returns an array of MockCallHistoryLogs
mockAgentHistory.firstCall() // returns the first MockCallHistoryLogs or undefined
mockAgentHistory.lastCall() // returns the last MockCallHistoryLogs or undefined
mockAgentHistory.nthCall(3) // returns the third MockCallHistoryLogs or undefined
```
67 changes: 67 additions & 0 deletions docs/docs/best-practices/mocking-request.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,73 @@ assert.deepEqual(badRequest, { message: 'bank account not found' })

Explore other MockAgent functionality [here](/docs/docs/api/MockAgent.md)

## Access agent history

Using a MockAgent also allows you to make assertions on the configuration used to make your http calls in your application.

Here is an example :

```js
// index.test.mjs
import { strict as assert } from 'assert'
blephy marked this conversation as resolved.
Show resolved Hide resolved
import { MockAgent, setGlobalDispatcher, fetch } from 'undici'
import { app } from './app.mjs'

// given an application server running on http://localhost:3000
await app.start()

const mockAgent = new MockAgent()

setGlobalDispatcher(mockAgent)

// this call is made (not intercepted)
await fetch(`http://localhost:3000/endpoint?query='hello'`, {
method: 'POST',
headers: { 'content-type': 'application/json' }
body: JSON.stringify({ data: '' })
})

// access to the call history of the MockAgent (which register every call made intercepted or not)
assert.ok(mockAgent.getCallHistory().calls().length === 1)
assert.strictEqual(mockAgent.getCallHistory().firstCall()?.fullUrl, `http://localhost:3000/endpoint?query='hello'`)
assert.strictEqual(mockAgent.getCallHistory().firstCall()?.body, JSON.stringify({ data: '' }))
assert.deepStrictEqual(mockAgent.getCallHistory().firstCall()?.searchParams, { query: 'hello' })
assert.strictEqual(mockAgent.getCallHistory().firstCall()?.port, '3000')
assert.strictEqual(mockAgent.getCallHistory().firstCall()?.host, 'localhost:3000')
assert.strictEqual(mockAgent.getCallHistory().firstCall()?.method, 'POST')
assert.strictEqual(mockAgent.getCallHistory().firstCall()?.path, '/endpoint')
assert.deepStrictEqual(mockAgent.getCallHistory().firstCall()?.headers, { 'content-type': 'application/json' })

// register a specific call history for a given interceptor (useful to filter call within a particular interceptor)
const mockPool = mockAgent.get('http://localhost:3000');

// we intercept a call and we register a specific MockCallHistory
mockPool.intercept({
path: '/second-endpoint',
}).reply(200, 'hello').registerCallHistory('second-endpoint-history')

assert.ok(mockAgent.getCallHistory().calls().length === 2) // MockAgent call history has registered the call too
assert.ok(mockAgent.getCallHistory('second-endpoint-history')?.calls().length === 1)
assert.strictEqual(mockAgent.getCallHistory('second-endpoint-history')?.firstCall()?.path, '/second-endpoint')
assert.strictEqual(mockAgent.getCallHistory('second-endpoint-history')?.firstCall()?.method, 'GET')

// clearing all call history

mockAgent.clearAllCallHistory()

assert.ok(mockAgent.getCallHistory().calls().length === 0)
assert.ok(mockAgent.getCallHistory('second-endpoint-history')?.calls().length === 0)

// clearing a particular history

mockAgent.getCallHistory().clear() // second-endpoint-history will not be cleared
mockAgent.getCallHistory('second-endpoint-history').clear() // it is not cleared
```

Calling `mockAgent.close()` will automatically clear and delete every call history for you.

Explore other MockAgent functionality [here](/docs/docs/api/MockAgent.md)

## Debug Mock Value

When the interceptor and the request options are not the same, undici will automatically make a real HTTP request. To prevent real requests from being made, use `mockAgent.disableNetConnect()`:
Expand Down
3 changes: 3 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const { InvalidArgumentError } = errors
const api = require('./lib/api')
const buildConnector = require('./lib/core/connect')
const MockClient = require('./lib/mock/mock-client')
const { MockCallHistory, MockCallHistoryLog } = require('./lib/mock/mock-call-history')
const MockAgent = require('./lib/mock/mock-agent')
const MockPool = require('./lib/mock/mock-pool')
const mockErrors = require('./lib/mock/mock-errors')
Expand Down Expand Up @@ -169,6 +170,8 @@ module.exports.connect = makeDispatcher(api.connect)
module.exports.upgrade = makeDispatcher(api.upgrade)

module.exports.MockClient = MockClient
module.exports.MockCallHistory = MockCallHistory
module.exports.MockCallHistoryLog = MockCallHistoryLog
module.exports.MockPool = MockPool
module.exports.MockAgent = MockAgent
module.exports.mockErrors = mockErrors
Expand Down
35 changes: 33 additions & 2 deletions lib/mock/mock-agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,21 @@ const {
kNetConnect,
kGetNetConnect,
kOptions,
kFactory
kFactory,
kMockCallHistory,
kMockAgentMockCallHistory,
kMockCallHistoryGetByName,
kMockCallHistoryClearAll,
kMockCallHistoryDeleteAll,
kMockCallHistoryAddLog
} = require('./mock-symbols')
const MockClient = require('./mock-client')
const MockPool = require('./mock-pool')
const { matchValue, buildMockOptions } = require('./mock-utils')
const { InvalidArgumentError, UndiciError } = require('../core/errors')
const Dispatcher = require('../dispatcher/dispatcher')
const PendingInterceptorsFormatter = require('./pending-interceptors-formatter')
const { MockCallHistory } = require('./mock-call-history')

class MockAgent extends Dispatcher {
constructor (opts) {
Expand All @@ -28,14 +35,15 @@ class MockAgent extends Dispatcher {
this[kIsMockActive] = true

// Instantiate Agent and encapsulate
if ((opts?.agent && typeof opts.agent.dispatch !== 'function')) {
if (opts?.agent && typeof opts.agent.dispatch !== 'function') {
throw new InvalidArgumentError('Argument opts.agent must implement Agent')
}
const agent = opts?.agent ? opts.agent : new Agent(opts)
this[kAgent] = agent

this[kClients] = agent[kClients]
this[kOptions] = buildMockOptions(opts)
this[kMockCallHistory] = new MockCallHistory(kMockAgentMockCallHistory)
}

get (origin) {
Expand All @@ -51,12 +59,23 @@ class MockAgent extends Dispatcher {
dispatch (opts, handler) {
// Call MockAgent.get to perform additional setup before dispatching as normal
this.get(opts.origin)

// guard if mockAgent.close (which delete all history) was called before a dispatch by inadvertency
// using MockCallHistory[kMockCallHistoryGetByName] instead of this[kMockCallHistory] because this[kMockCallHistory] would then be still populated
if (MockCallHistory[kMockCallHistoryGetByName](kMockAgentMockCallHistory) === undefined) {
this[kMockCallHistory] = new MockCallHistory(kMockAgentMockCallHistory)
}

// add call history log even on non intercepted and intercepted calls (every call)
this[kMockCallHistory][kMockCallHistoryAddLog](opts)

return this[kAgent].dispatch(opts, handler)
}

async close () {
await this[kAgent].close()
this[kClients].clear()
MockCallHistory[kMockCallHistoryDeleteAll]()
}

deactivate () {
Expand Down Expand Up @@ -85,6 +104,18 @@ class MockAgent extends Dispatcher {
this[kNetConnect] = false
}

getCallHistory (name) {
if (name == null) {
return MockCallHistory[kMockCallHistoryGetByName](kMockAgentMockCallHistory)
}

return MockCallHistory[kMockCallHistoryGetByName](name)
}

clearAllCallHistory () {
MockCallHistory[kMockCallHistoryClearAll]()
}

// This is required to bypass issues caused by using global symbols - see:
// https://github.com/nodejs/undici/issues/1447
get isMockActive () {
Expand Down
Loading
Loading