├── .eslintrc ├── .gitignore ├── CHANGELOG.md ├── LICENSE-MIT ├── README.md ├── index.js ├── package.json ├── tests.js └── tmp └── .placeholder /.eslintrc: -------------------------------------------------------------------------------- 1 | --- 2 | rules: 3 | quotes: 4 | - 2 5 | - single 6 | no-bitwise: 0 7 | no-unused-expressions: 0 8 | max-params: 0 9 | max-statements: 0 10 | no-invalid-this: 0 11 | prefer-spread: 0 12 | no-magic-numbers: 0 13 | complexity: 14 | - 2 15 | - 18 16 | 17 | extends: 18 | - "defaults/configurations/walmart/es6-node" 19 | 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | node_modules/ 3 | tmp/ 4 | 5 | npm-debug.log 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | NEW in v3.1: Async hook for mocked call return type serializer/deserializer 2 | =========================================================================== 3 | Easy-fix supports options to customize the mock/fixture values. This is achieved by allowing functions to be passed in (as named options) that control the serialization/deserialization of the target function call return value and callback/Promise resolution arguments. 4 | 5 | While this works for most use-cases, there is an important use-case that has not yet been supported. The target function may start some asynchronous work which is not captured by a callback or a Promise, but rather by returning some object that produces the result later. An example of this is an an HTTP request which returns a Stream object representing the response to the request. As the easy-fix options of `returnValueSerializer` and `returnValueDeserializer` work synchronously, there is no clear and easy way to serialize the asynchronous result of such a stream object into the mock file. 6 | 7 | This is improved in easy-fix 3.1, which provides an additional parameter to the `returnValueSerializer`. The additional parameter is a callback, and the arguments of that callback are written into the mock file. Accordingly, as tests are replayed, these values are read from the mock files and passed into the `returnValueDeserializer` as a new, second argument. 8 | 9 | 10 | NEW in v3: Workflow features 11 | ============================ 12 | 13 | Easy-fix v3 will default to a more readable serialization of JSON data. Previous versions would serialize call arguments, return and response values as (an escaped) string, and serialize the whole object of mock data to write the mock file to disk. A result of this double serialization is arguments and response/return values would each be encoded as a single (often very long) line. With the changes in v3, the JSON object of mock data is only serialized once (by default) and result is typically more readable. Changes in the mock files also typically look better in diff results. 14 | 15 | Errors as a first argument in callbacks and rejected promises are re-instantiated by easy-fix, when running in replay mode. This avoids a potentially-confusing behavior where easy-fix will intercept an Error result, but replay it as a deserialized Object. Note that the new behavior will only reinstantiate an Error, not any derived Error types. If your tests are sensitive to different Error types, you can make sure the correct types are used by defining a `responseSerializer` and `responseDeserializer`. 16 | 17 | An optional log file will be written with mock file read/write attempts, if a filename is passed in to the easy-fix options. This may help investigate failing tests, where easy-fix is attempting to read a mock file that does not exist. 18 | 19 | A cache of mock files will prevent multiple reads of the same path. This will reduce the number of synchronous file reads for repeated tests, which may make performance metrics more accurate. 20 | 21 | A named file may be specified as the 'filepath' option, preventing easy-fix from generating a filename from a hash of the calling arguments. This may be useful in keeping tests robust across changes to the arguments of the wrapped target function. This option should be used with caution: changes to the calling arguments typically represent changes to behavior of the target function, but the mock file named with this argument will be used (in replay mode) regardless of the calling function arguments. 22 | 23 | 24 | NEW in v2: Support for Promises 25 | =============================== 26 | 27 | Easy-fix v2 supports Promises. Use the same method `wrapAsyncMethod` to wrap a function that returns a Promise, and the Promise will operate according to the easy-fix TEST_MODE, just like regular async functions. In 'live' mode, the Promise will work as usual. In 'capture' mode, it will generate a test fixture. In 'replay' mode, the wrapped function will not be called, and the returned Promise will be resolve or rejected with the data found in the fixture. 28 | 29 | There is another small (but potentially breaking) change. Easy-fix v2 now supports the return values of the wrapped functions. Previously, in easy-fix v1, calling a function wrapped by `wrapAsyncMethod` would allways return null (in capture & replay modes). Now, the return value is serialized to the fixture file, and returned in all modes. 30 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2016 WalmartLabs 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | Easy-fix: record & replay test data for flexible integration testing 3 | ==================================================================== 4 | 5 | Opinions diverge on how to do integration testing. One camp says: "mock your input data to isolate the target code" but tests using mock data can lose fidelity with changing real-world systems. The other camp says: "let your integration tests run live. Side effects are no problem" but those tests can run slow (for network latency, etc) and might require you to be on a private network. Neither camp wins! 6 | 7 | Why choose? This module helps integration tests capture and replay test data. This allows tests to run in "live" mode, interacting with remote systems/db, or in "replay" mode, isolated to using serialized mock data, with no side effects. This is integration testing zen. 8 | 9 | NEW in v3 10 | --------- 11 | Several new features include 12 | * better serialization for whitespace 13 | * Error reinstantiation 14 | * mock file access log 15 | * a file cache for mock data 16 | * named mock files 17 | 18 | Notes on the new options have been added below. 19 | See the [changelog](CHANGELOG.md) for more details. 20 | 21 | NEW in v2 22 | --------- 23 | Easy-fix v2 now supports promises! See the [changelog](CHANGELOG.md) for details. 24 | 25 | Installing 26 | ---------- 27 | `npm install easy-fix --save-dev` 28 | 29 | 30 | Usage & documentation 31 | --------------------- 32 | 33 | Easy-fix exposes only two methods: "wrapAsyncMethod" and "restore" 34 | 35 | Let's start with an example. This test shows sinon stub replace with the easy-fix equivalent: 36 | 37 | ```javascript 38 | 39 | let wrapper; 40 | 41 | // set up the stubs/mocks: 42 | before(function () { 43 | 44 | // Perhaps you use stubs, something like this: 45 | // sinon.stub(productFees, 'getFeesForUpcs', /* stub function here */ ); 46 | 47 | // Let's replace that and use easy-fix: 48 | wrapper = easyFix.wrapAsyncMethod(productFees, 'getFeesForUpcs', { 49 | dir: 'test/captured-data', // directory for the captured test data 50 | prefix: 'product-fees', // filenames are prefixed with this string 51 | }); 52 | }; 53 | 54 | it('gets linked upcs', function (done) { 55 | var upcs = [ 56 | '0007800015274', 57 | '0069766210858' 58 | ]; 59 | 60 | productFees.getFeesForUpcs(upcs, function (err, fees) { 61 | expect(err).to.not.exist; 62 | expect(fees).to.exist; 63 | expect(_.keys(fees)).to.have.length.above(2); 64 | done(); 65 | }); 66 | }); 67 | 68 | after(function () { 69 | wrapper.restore() // remove stubs 70 | }); 71 | ``` 72 | 73 | If you had no 'before' setup method, the test would hit the database. 74 | 75 | If you used the sinon stub, you'd have to plumb in your own mock function. This is typically how people feed in the mock data. 76 | 77 | Use easy-fix much like the sinon.stub - pass in an object, the name of a method, and an options hash. Easy-fix will then operate in one of three modes... 78 | 79 | Test modes 80 | ---------- 81 | 82 | Modes are specified by the `TEST_MODE` environment variable, and they can be overridden as the 'mode' in the options hash. The modes are: 83 | 84 | * "live": test runs live. Easy-fix simply falls back onto the target function. 85 | * "capture": test runs live, but the arguments and response are captured and written to disk. 86 | * "replay": test does not run live - the function is mocked by the captured data. 87 | 88 | Options 89 | ------- 90 | 91 | * `dir: `: test data is written into this directory. This defaults to "test/data". 92 | * `prefix: `: test data filenames are prefixed with this string. This defaults to the name of the target function. 93 | * `mode: `: override the TEST_MODE environment variable. In the absence of the TEST_MODE and this option, the mode defaults to "replay". 94 | * `callbackSwap: `: allow an alternate function to monkey-patch the target function callback. If the target function (under test) does not follow the nodejs convention of having a callback as it's last argument, you'll need to use this option to provide a custom function to swap the callbacks. 95 | * `reinstantiateErrors: `: if the first argument to a callback is an Errror, or the first argument for a rejected Promise is an Error, easy-fix will attempt to reinstantiate this Error (when in replay mode). Default is `true`. 96 | * `filepath: `: capture/replay a mock in the named file path (joined with the `dir` option). This avoids the filename being derived from a hash of the calling arguments to the target function. 97 | * `sinon: `: if your project uses sinon, you can pass in the module here, and the wrapped target function will be a sinon stub. This adds functionality to the easy-fix wrapped function object, but does not change the behavior of easy-fix. Allowing sinon as an option avoids taking it as a dependency. 98 | * `log: `: A file will be appended with lines describing the names of the mock files read and written. 99 | 100 | Options - serialization 101 | ----------------------- 102 | 103 | * `argumentSerializer: `: an alternate serialization of the target function arguments. The default is a cycle-safe JSON serializer. Easy-fix will match responses to a hash of the serialized call arguments. This is useful for deduplicating test data where you expect the call arguments will be different for each call but do not require a unique response (perhaps for a timestamp or uuid). 104 | * `responseSerializer: `: use an alternate serialization of the target function callback arguments. This may be useful, for example, in removing details from a long response, if the test requires only some of the unaffected details. Note that this argument applies to the callback arguments for Promise resolution/rejection as well as an asynchronous function callback. 105 | * `responseDeserializer: `: use an alternate deserialization of the target function callback arguments. The default is JSON.parse. This is typically only useful if you specify a responseSerializer. This may be useful to reinstantiate a derived Object or Error type, if needed. 106 | * `returnValueSerializer: `: allow an alternate serialization to JSON.stringify on the target function return value. This may be useful, for example, in removing details from a long return value, if the test requires only some of the unaffected details. A callback is provided to allow the test to capture asynchronous information produced by the return value. This may help with capturing values produced by streams, for example. 107 | * `returnValueDeserializer: `: use an alternate deserialization of the return value of the target function. The default is JSON.parse. This is typically only useful if you specify a returnValueSerializer. This may be useful to reinstantiate a derived Object or Error type, if needed. The second argument is the provided if any data was captured by the callback to the `returnValueSerializer`. 108 | 109 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs'); 4 | const os = require('os'); 5 | const path = require('path'); 6 | const crypto = require('crypto'); 7 | 8 | const modes = { 9 | live: 'live', 10 | capture: 'capture', 11 | replay: 'replay' 12 | }; 13 | 14 | const mockFileCache = {}; 15 | 16 | const HASH_LENGTH = 12; 17 | 18 | const NICE_ERR_HEADER = 'This test (in replay mode) could not read the expected mock data from'; // eslint-disable-line max-len 19 | const NICE_ERR_SERIALIZATION_DESCRIPTOR = 'Serialized arguments'; 20 | const NICE_ERR_FOOTER = 'If you have not already, try running this test in capture mode to generate new test fixtures. If you continue to see this error, a likely cause is differing (frequently changing) argument for the wrapped asynchronous task. This can be mitigated by defining an argumentSerializer option that ignores the frequently-changing argument.'; // eslint-disable-line max-len 21 | const NICE_ERR_PROMISE = 'easy-fix retained no resolution/rejection arguments for this wrapped promise'; // eslint-disable-line max-len 22 | const NOTE = '(error reinstantiated by easy-fix)'; 23 | 24 | const getNiceError = (file, details) => { 25 | return `${NICE_ERR_HEADER} "${file}"\n\n${NICE_ERR_SERIALIZATION_DESCRIPTOR}:\n${details}\n\n${NICE_ERR_FOOTER}`; // eslint-disable-line max-len 26 | }; 27 | 28 | /** 29 | * Safe JSON Serializer will not fail in the face of circular references 30 | * Derived heavily from @isaacs ISC Licensed json-stringify-safe repo 31 | * https://github.com/isaacs/json-stringify-safe/blob/master/stringify.js 32 | * @param {?function} replacer to transform serialized values 33 | * @param {?function} cycleReplacer to transform cyclical values 34 | * @returns {string} serialized value of the object 35 | */ 36 | const stringifySafeSerializer = (replacer, cycleReplacer) => { 37 | const stack = []; 38 | const keys = []; 39 | 40 | if (!cycleReplacer) { 41 | cycleReplacer = (key, value) => { 42 | if (stack[0] === value) { 43 | return '[Circular ~]'; 44 | } 45 | return `[Circular ~.${keys.slice(0, stack.indexOf(value)).join('.')}]`; 46 | }; 47 | } 48 | 49 | return function (key, value) { 50 | if (stack.length > 0) { 51 | const thisPos = stack.indexOf(this); 52 | ~thisPos ? stack.splice(thisPos + 1) : stack.push(this); 53 | ~thisPos ? keys.splice(thisPos, Infinity, key) : keys.push(key); 54 | if (~stack.indexOf(value)) { 55 | value = cycleReplacer.call(this, key, value); 56 | } 57 | } else { 58 | stack.push(value); 59 | } 60 | 61 | return replacer ? replacer.call(this, key, value) : value; 62 | }; 63 | }; 64 | 65 | exports.stringifySafe = (obj, replacer, spaces, cycleReplacer) => { 66 | return JSON.stringify(obj, stringifySafeSerializer(replacer, cycleReplacer), spaces); 67 | }; 68 | 69 | const noop = (arg) => arg; 70 | 71 | exports.wrapAsyncMethod = function (obj, method, optionsArg) { 72 | const originalFn = obj[method]; 73 | const options = {}; 74 | // allow the options argument to simply be the directory option 75 | options.dir = typeof optionsArg === 'string' ? optionsArg : optionsArg.dir || 'test/data'; 76 | options.prefix = optionsArg.prefix || method; 77 | options.mode = optionsArg.mode || modes[process.env.TEST_MODE || modes.replay]; 78 | options.log = optionsArg.log; 79 | if (optionsArg.reinstantiateErrors !== undefined) { 80 | options.reinstantiateErrors = optionsArg.reinstantiateErrors; 81 | } else { 82 | options.reinstantiateErrors = true; 83 | } 84 | options.filepath = optionsArg.filepath; 85 | options.callbackSwap = optionsArg.callbackSwap || function (args, newCallback) { 86 | const origCallback = args[args.length - 1]; 87 | args[args.length - 1] = newCallback; 88 | return origCallback; 89 | }; 90 | options.argumentSerializer = optionsArg.argumentSerializer || noop; 91 | options.responseSerializer = optionsArg.responseSerializer || noop; 92 | options.returnValueSerializer = optionsArg.returnValueSerializer || noop; 93 | 94 | const responseDeserializer = optionsArg.responseDeserializer || 95 | (optionsArg.responseSerializer ? JSON.parse : noop); 96 | 97 | const returnValueDeserializer = optionsArg.returnValueDeserializer || 98 | (optionsArg.returnValueSerializer ? JSON.parse : noop); 99 | 100 | options.sinon = optionsArg.sinon; 101 | 102 | const wrapper = function () { 103 | const callingArgs = Array.from(arguments); 104 | const self = this; 105 | 106 | if (options.mode === modes.live) { 107 | // no fixtures, no problems. We're done here. 108 | return originalFn.apply(self, callingArgs); 109 | } 110 | 111 | const wrappedCallData = { 112 | callArgs: options.argumentSerializer(callingArgs) 113 | }; 114 | const argStr = exports.stringifySafe(wrappedCallData.callArgs); 115 | const hashKey = 116 | crypto 117 | .createHash('sha256') 118 | .update(argStr) 119 | .digest('hex') 120 | .slice(0, HASH_LENGTH); 121 | const filepath = path.join(options.dir, 122 | options.filepath || `${options.prefix}-${hashKey}.json`); 123 | const writeWrappedCallData = () => { 124 | fs.writeFileSync( 125 | filepath, 126 | exports.stringifySafe(wrappedCallData, null, ' ') + os.EOL, 127 | 'utf8'); 128 | if (options.log) { 129 | fs.appendFileSync(options.log, `wrote new mock ${filepath}\n`, 'utf8'); 130 | } 131 | }; 132 | 133 | let origCallback; 134 | if (typeof callingArgs[callingArgs.length - 1] === 'function') { 135 | origCallback = options.callbackSwap.apply(self, [callingArgs, function () { 136 | const callbackArgs = Array.from(arguments); 137 | wrappedCallData.callbackArgs = options.responseSerializer(callbackArgs); 138 | if (callbackArgs[0] instanceof Error) { 139 | wrappedCallData.calledBackWithError = { 140 | message: callbackArgs[0].message, 141 | stack: callbackArgs[0].stack 142 | }; 143 | } 144 | writeWrappedCallData(); 145 | origCallback.apply(this, callbackArgs); 146 | }]); 147 | } 148 | 149 | if (options.mode === modes.capture) { 150 | // mode is capture 151 | let returnValue = originalFn.apply(self, callingArgs); 152 | wrappedCallData.returnedPromise = returnValue && !!returnValue.then; 153 | if (wrappedCallData.returnedPromise) { 154 | returnValue = 155 | returnValue 156 | .then(function () { 157 | const promiseResolutionArgs = Array.from(arguments); 158 | return new Promise((resolve) => { 159 | wrappedCallData.promiseResolutionArgs = 160 | options.responseSerializer(promiseResolutionArgs); 161 | writeWrappedCallData(); 162 | return resolve.apply(this, promiseResolutionArgs); 163 | }); 164 | }) 165 | .catch(function () { 166 | const promiseRejectionArgs = Array.from(arguments); 167 | if (promiseRejectionArgs[0] instanceof Error) { 168 | wrappedCallData.rejectedWithError = { 169 | message: promiseRejectionArgs[0].message, 170 | stack: promiseRejectionArgs[0].stack 171 | }; 172 | } 173 | return new Promise((resolve, reject) => { 174 | wrappedCallData.promiseRejectionArgs = 175 | options.responseSerializer(promiseRejectionArgs); 176 | writeWrappedCallData(); 177 | return reject.apply(this, promiseRejectionArgs); 178 | }); 179 | }); 180 | } else { 181 | wrappedCallData.returnValue = options.returnValueSerializer(returnValue, function () { 182 | const returnValueAsyncCallbackArgs = Array.from(arguments); 183 | wrappedCallData.returnValueAsyncCallbackArgs = returnValueAsyncCallbackArgs; 184 | writeWrappedCallData(); 185 | }); 186 | } 187 | return returnValue; 188 | } 189 | 190 | // mode is replay 191 | let cannedData; 192 | try { 193 | if (!mockFileCache[filepath]) { 194 | mockFileCache[filepath] = fs.readFileSync(filepath, 'utf8'); 195 | } 196 | cannedData = mockFileCache[filepath]; 197 | } catch (err) { 198 | if (err.code === 'ENOENT') { 199 | if (options.log) { 200 | fs.appendFileSync(options.log, `could not find ${filepath}\n`, 'utf8'); 201 | } 202 | throw new Error(getNiceError(filepath, argStr)); 203 | } 204 | throw err; 205 | } 206 | if (options.log) { 207 | fs.appendFileSync(options.log, `read mock ${filepath}\n`, 'utf8'); 208 | } 209 | const cannedJson = JSON.parse(cannedData); 210 | if (cannedJson.callbackArgs) { 211 | process.nextTick(() => { 212 | const callbackArgs = responseDeserializer(cannedJson.callbackArgs); 213 | if (options.reinstantiateErrors && 214 | cannedJson.calledBackWithError && 215 | callbackArgs[0] instanceof Error === false) { 216 | const err = new Error(`${cannedJson.calledBackWithError.message} ${NOTE}`); 217 | // Errors may contain additional properties that we'll want to write back 218 | Object.assign(err, callbackArgs[0]); 219 | err.stack = cannedJson.calledBackWithError.stack; 220 | callbackArgs[0] = err; 221 | } 222 | origCallback.apply(self, callbackArgs); 223 | }); 224 | } 225 | if (cannedJson.returnedPromise) { 226 | return new Promise((resolve, reject) => { 227 | process.nextTick(() => { 228 | if (cannedJson.promiseResolutionArgs) { 229 | return resolve.apply(self, responseDeserializer(cannedJson.promiseResolutionArgs)); 230 | } 231 | if (cannedJson.promiseRejectionArgs) { 232 | const promiseRejectionArgs = responseDeserializer(cannedJson.promiseRejectionArgs); 233 | if (options.reinstantiateErrors && 234 | cannedJson.rejectedWithError && 235 | promiseRejectionArgs[0] instanceof Error === false) { 236 | const err = new Error(`${cannedJson.rejectedWithError.message} ${NOTE}`); 237 | // Errors may contain additional properties that we'll want to write back 238 | Object.assign(err, promiseRejectionArgs[0]); 239 | err.stack = cannedJson.rejectedWithError.stack; 240 | promiseRejectionArgs[0] = err; 241 | } 242 | return reject.apply(self, promiseRejectionArgs); 243 | } 244 | return reject(new Error(NICE_ERR_PROMISE)); 245 | }); 246 | }); 247 | } 248 | return returnValueDeserializer(cannedJson.returnValue, cannedJson.returnValueAsyncCallbackArgs); 249 | }; 250 | 251 | if (options.sinon) { 252 | return options.sinon.stub(obj, method, wrapper); 253 | } 254 | obj[method] = wrapper; 255 | wrapper.restore = function () { 256 | obj[method] = originalFn; 257 | }; 258 | return wrapper; 259 | }; 260 | 261 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "easy-fix", 3 | "version": "3.1.0", 4 | "description": "easily capture & replay mock test data", 5 | "main": "index.js", 6 | "scripts": { 7 | "lint": "eslint index.js tests.js", 8 | "test": "mocha tests.js" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git@github.com:walmartlabs/easy-fix.git" 13 | }, 14 | "keywords": [ 15 | "test", 16 | "mock", 17 | "fixture" 18 | ], 19 | "author": "Dan Rathbone (rathbone1200cc)", 20 | "license": "MIT", 21 | "dependencies": {}, 22 | "devDependencies": { 23 | "babel-eslint": "6.0.4", 24 | "chai": "3.5.0", 25 | "eslint": "1.10.3", 26 | "eslint-config-defaults": "9.0.0", 27 | "eslint-plugin-filenames": "0.2.0", 28 | "mocha": "2.5.3", 29 | "sinon": "1.17.3" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /tests.js: -------------------------------------------------------------------------------- 1 | /* globals describe, beforeEach, afterEach, it */ 2 | 'use strict'; 3 | 4 | const sinon = require('sinon'); 5 | const expect = require('chai').expect; 6 | const easyFix = require('./index'); 7 | const stream = require('stream'); 8 | 9 | const expectedReturnValue = 'I am a function return value'; 10 | const errorMessage = 'This is a predictable error'; 11 | const expectedStreamingValue = 'This string gets streamed in chunks'; 12 | const thingToTest = { 13 | state: 0, 14 | incStateAsync: (stateArg, callback) => { 15 | thingToTest.state = stateArg.val; 16 | process.nextTick(() => { 17 | thingToTest.state += 1; 18 | callback(null, thingToTest.state); 19 | }); 20 | return expectedReturnValue; 21 | }, 22 | causeAsyncError: (callback) => { 23 | process.nextTick(() => { 24 | const error = new Error(errorMessage); 25 | error.otherProperty = 'blah'; 26 | callback(error); 27 | }); 28 | return expectedReturnValue; 29 | }, 30 | incStatePromise: (stateArg) => { 31 | return new Promise((resolve) => { 32 | thingToTest.state = stateArg.val; 33 | process.nextTick(() => { 34 | thingToTest.state += 1; 35 | resolve(thingToTest.state); 36 | }); 37 | }); 38 | }, 39 | promiseThatRejects: () => { 40 | return new Promise((resolve, reject) => { 41 | process.nextTick(() => { 42 | const err = new Error(errorMessage); 43 | err.otherProperty = 'blah'; 44 | reject(err); 45 | }); 46 | }); 47 | }, 48 | streamSomething: () => { 49 | const stringStream = new stream.PassThrough(); 50 | let streamIndex = 0; 51 | const continueSreaming = () => { 52 | if (streamIndex < expectedStreamingValue.length) { 53 | stringStream.push(expectedStreamingValue[streamIndex]); 54 | streamIndex += 1; 55 | setTimeout(continueSreaming, 5); 56 | return; 57 | } 58 | stringStream.push(null); 59 | }; 60 | continueSreaming(); 61 | return stringStream; 62 | }, 63 | resetState: () => { 64 | thingToTest.state = 0; 65 | } 66 | }; 67 | 68 | let asyncStub; 69 | let promiseStub; 70 | let causeErrorStub; 71 | let promiseRejectStub; 72 | let streamStub; 73 | const runSharedTests = (expectTargetFnCalls) => { 74 | 75 | it('falls back onto wrapped method', (done) => { 76 | const foundReturnValue = thingToTest.incStateAsync({ val: 9 }, (err, state) => { 77 | expect(foundReturnValue).to.equal(expectedReturnValue); 78 | expect(state).to.equal(10); 79 | const expectedTargetState = expectTargetFnCalls ? 10 : 0; 80 | expect(thingToTest.state).to.equal(expectedTargetState); 81 | expect(asyncStub.callCount).to.equal(1); 82 | done(); 83 | }); 84 | }); 85 | 86 | it('handles errors gracefully', (done) => { 87 | const foundReturnValue = thingToTest.causeAsyncError((err) => { 88 | expect(foundReturnValue).to.equal(expectedReturnValue); 89 | expect(err instanceof Error).to.equal(true); 90 | expect(causeErrorStub.callCount).to.equal(1); 91 | done(); 92 | }); 93 | }); 94 | 95 | it('additional properties are present on errors', (done) => { 96 | thingToTest.causeAsyncError((err) => { 97 | expect(err.stack).to.not.include('easy-fix/index.js'); 98 | expect(err.otherProperty).to.eql('blah'); 99 | done(); 100 | }); 101 | }); 102 | 103 | it('works with mulitple calls', (done) => { 104 | const firstReturned = thingToTest.incStateAsync({ 105 | val: 98 106 | }, (firstErr, stateAfterFirstInc) => { 107 | const secondReturned = thingToTest.incStateAsync({ 108 | val: stateAfterFirstInc 109 | }, (secondErr, stateAfterSecondInc) => { 110 | expect(firstReturned).to.equal(expectedReturnValue); 111 | expect(secondReturned).to.equal(expectedReturnValue); 112 | expect(stateAfterSecondInc).to.equal(100); 113 | const expectedTargetState = expectTargetFnCalls ? 100 : 0; 114 | expect(thingToTest.state).to.equal(expectedTargetState); 115 | expect(asyncStub.callCount).to.equal(2); 116 | done(); 117 | }); 118 | }); 119 | }); 120 | 121 | it('handles circular references gracefully', (done) => { 122 | const testObj = { val: 0 }; 123 | testObj.circ = testObj; // add circular reference 124 | thingToTest.incStateAsync(testObj, (err, state) => { 125 | expect(state).to.equal(1); 126 | const expectedTargetState = expectTargetFnCalls ? 1 : 0; 127 | expect(thingToTest.state).to.equal(expectedTargetState); 128 | expect(asyncStub.callCount).to.equal(1); 129 | done(); 130 | }); 131 | }); 132 | 133 | it('works with promises', (done) => { 134 | const testObj = { val: 49 }; 135 | thingToTest.incStatePromise(testObj) 136 | .then((state) => { 137 | expect(state).to.equal(50); 138 | const expectedTargetState = expectTargetFnCalls ? 50 : 0; 139 | expect(thingToTest.state).to.equal(expectedTargetState); 140 | expect(promiseStub.callCount).to.equal(1); 141 | done(); 142 | }) 143 | .catch(done); 144 | }); 145 | 146 | it('handles promise rejection gracefully', (done) => { 147 | thingToTest.promiseThatRejects() 148 | .catch((err) => { 149 | let validationError; 150 | try { 151 | expect(err instanceof Error).to.equal(true); 152 | expect(promiseRejectStub.callCount).to.equal(1); 153 | } catch (caughtError) { 154 | validationError = caughtError; 155 | } 156 | done(validationError); 157 | }); 158 | }); 159 | 160 | it('handles a return value that does async work, like a stream', (done) => { 161 | const stringStream = thingToTest.streamSomething(); 162 | const chunks = []; 163 | stringStream.on('data', (chunk) => { 164 | chunks.push(chunk); 165 | }); 166 | stringStream.on('end', () => { 167 | const body = Buffer.concat(chunks).toString(); 168 | expect(body).to.equal(expectedStreamingValue); 169 | done(); 170 | }); 171 | }); 172 | 173 | it('attaches correct stack and metadata to error', () => { 174 | return thingToTest.promiseThatRejects() 175 | .catch((err) => { 176 | expect(err instanceof Error).to.equal(true); 177 | expect(promiseRejectStub.callCount).to.equal(1); 178 | expect(err.stack).to.not.include('easy-fix/index.js'); 179 | expect(err.otherProperty).to.eql('blah'); 180 | }); 181 | }); 182 | 183 | }; 184 | 185 | // The call to incStateAsync includes a parameter (val) 186 | // that sets the state, but that won't happen when the 187 | // method is wrapped and called in reply mode. 188 | // So we reset the state with resetState before each test. 189 | beforeEach(() => { thingToTest.resetState(); }); 190 | 191 | const reverseSerializer = (args) => { 192 | const str = easyFix.stringifySafe(args, null, ''); 193 | return Array.from(str).reverse().join(''); 194 | }; 195 | 196 | const reverseDeserializer = (str) => { 197 | const unreversed = Array.from(str).reverse().join(''); 198 | const args = JSON.parse(unreversed, null, ''); 199 | return args; 200 | }; 201 | 202 | const setupMocks = (mode, useSerializers) => { 203 | beforeEach(() => { 204 | const options = { 205 | mode, 206 | sinon, 207 | dir: 'tmp' 208 | }; 209 | if (useSerializers) { 210 | options.argumentSerializer = reverseSerializer; 211 | options.responseSerializer = reverseSerializer; 212 | options.returnValueSerializer = reverseSerializer; 213 | options.argumentDeserializer = reverseDeserializer; 214 | options.responseDeserializer = reverseDeserializer; 215 | options.returnValueDeserializer = reverseDeserializer; 216 | } 217 | asyncStub = easyFix.wrapAsyncMethod(thingToTest, 'incStateAsync', options); 218 | promiseStub = easyFix.wrapAsyncMethod(thingToTest, 'incStatePromise', options); 219 | causeErrorStub = easyFix.wrapAsyncMethod(thingToTest, 'causeAsyncError', options); 220 | promiseRejectStub = easyFix.wrapAsyncMethod(thingToTest, 'promiseThatRejects', options); 221 | 222 | // a streaming return type requires some custom handling: 223 | streamStub = easyFix.wrapAsyncMethod(thingToTest, 'streamSomething', 224 | Object.assign({}, options, { 225 | returnValueSerializer: (stringStream, callback) => { 226 | const chunks = []; 227 | stringStream.on('data', (chunk) => { 228 | chunks.push(chunk); 229 | }); 230 | stringStream.on('end', () => { 231 | const body = Buffer.concat(chunks).toString(); 232 | callback(body); 233 | }); 234 | }, 235 | returnValueDeserializer: (returnValue, returnValueAsyncCallbackArgs) => { 236 | const stringStream = new stream.PassThrough(); 237 | stringStream.push(returnValueAsyncCallbackArgs[0]); 238 | stringStream.push(null); 239 | return stringStream; 240 | } 241 | }) 242 | ); 243 | }); 244 | }; 245 | 246 | afterEach(() => { 247 | asyncStub.restore(); 248 | promiseStub.restore(); 249 | causeErrorStub.restore(); 250 | promiseRejectStub.restore(); 251 | streamStub.restore(); 252 | }); 253 | 254 | describe('wrapAsyncMethod (live mode)', () => { 255 | setupMocks('live'); 256 | runSharedTests(true); 257 | }); 258 | 259 | describe('wrapAsyncMethod (capture mode)', () => { 260 | setupMocks('capture'); 261 | runSharedTests(true); 262 | }); 263 | 264 | describe('wrapAsyncMethod (capture mode) with custom serializers', () => { 265 | setupMocks('capture', true); 266 | runSharedTests(true); 267 | }); 268 | 269 | describe('wrapAsyncMethod (replay mode)', () => { 270 | setupMocks('replay'); 271 | runSharedTests(false); 272 | 273 | describe('if no matching mock data is found', () => { 274 | const fnWithoutMocks = (cb) => { 275 | thingToTest.incStateAsync({ 276 | foo: 'bar' 277 | }, () => { cb(new Error('Failed to throw')); }); 278 | }; 279 | 280 | it('should throw an error with details about the expected data', (done) => { 281 | expect(() => fnWithoutMocks(done)).to.throw(); 282 | done(); 283 | }); 284 | }); 285 | }); 286 | 287 | describe('wrapAsyncMethod (replay mode) with custom deserializers', () => { 288 | setupMocks('replay', true); 289 | runSharedTests(false); 290 | }); 291 | 292 | -------------------------------------------------------------------------------- /tmp/.placeholder: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/walmartlabs/easy-fix/87ef07b3388bc0d8c41df0b0a9db22f77ccf4ce6/tmp/.placeholder --------------------------------------------------------------------------------