Repository: skovhus/jest-codemods
Pull request #242 (2022-04-06 21:27:38)

Add sinon codemod
This is the first pass at a sinon codemod - see [relevant thread](https://github.com/skovhus/jest-codemods/issues/68). We've used this on our codebase to help migrate ~80% (1800+ files) of sinon use cases.

Thanks to @jordalgo for the [initial POC](https://github.com/jordalgo/jest-codemods/blob/7de97c1d0370c7915cf5e5cc2a860bc5dd96744b/src/transformers/sinon.js).

cc @lencioni @danbeam


skovhus: This is awesome. Would you mind updating the README. I also believe we have a warning if we see sinon mocks, that should be changed to suggest using the transformer. Depending on the stability of it we might consider including it as a default transformer. 
catc: @skovhus updated both README + warning

Patch:
diff --git a/README.md b/README.md
index 543a65f..5fd7e5f 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,8 @@ Codemods that simplify migrating JavaScript and TypeScript test files from
 [Mocha](https://github.com/mochajs/mocha),
 [proxyquire](https://github.com/thlorenz/proxyquire),
 [Should.js](https://github.com/tj/should.js/),
-[Tape](https://github.com/substack/tape)
+[Tape](https://github.com/substack/tape),
+[Sinon](https://github.com/sinonjs/),
 and
 [Node-Tap](https://github.com/tapjs/node-tap)
 to [Jest](https://facebook.github.io/jest/).
@@ -82,6 +83,7 @@ $ jscodeshift -t node_modules/jest-codemods/dist/transformers/jasmine-this.js te
 $ jscodeshift -t node_modules/jest-codemods/dist/transformers/mocha.js test-folder
 $ jscodeshift -t node_modules/jest-codemods/dist/transformers/should.js test-folder
 $ jscodeshift -t node_modules/jest-codemods/dist/transformers/tape.js test-folder
+$ jscodeshift -t node_modules/jest-codemods/dist/transformers/sinon.js test-folder
 ```
 
 ## Test environment: Jest on Node.js or other
diff --git a/src/cli/index.ts b/src/cli/index.ts
index b0bad69..3f5c1f2 100644
--- a/src/cli/index.ts
+++ b/src/cli/index.ts
@@ -60,6 +60,7 @@ const TRANSFORMER_JASMINE_THIS = 'jasmine-this'
 const TRANSFORMER_MOCHA = 'mocha'
 const TRANSFORMER_SHOULD = 'should'
 const TRANSFORMER_TAPE = 'tape'
+const TRANSFORMER_SINON = 'sinon'
 
 const ALL_TRANSFORMERS = [
   // TRANSFORMER_CHAI_SHOULD & TRANSFORMER_SHOULD doesn't have import detection
@@ -70,6 +71,7 @@ const ALL_TRANSFORMERS = [
   TRANSFORMER_MOCHA,
   TRANSFORMER_TAPE,
   TRANSFORMER_JASMINE_THIS,
+  TRANSFORMER_SINON,
 ]
 
 const TRANSFORMER_INQUIRER_CHOICES = [
@@ -113,6 +115,10 @@ const TRANSFORMER_INQUIRER_CHOICES = [
     name: 'Tape / Node-Tap',
     value: TRANSFORMER_TAPE,
   },
+  {
+    name: 'Sinon',
+    value: TRANSFORMER_SINON,
+  },
   {
     name: 'All of the above (use with care)!',
     value: 'all',
diff --git a/src/transformers/sinon.ts b/src/transformers/sinon.ts
new file mode 100644
index 0000000..1e53c43
--- /dev/null
+++ b/src/transformers/sinon.ts
@@ -0,0 +1,663 @@
+import core, { API, FileInfo } from 'jscodeshift'
+
+import {
+  chainContainsUtil,
+  createCallUtil,
+  getNodeBeforeMemberExpressionUtil,
+  isExpectCallUtil,
+} from '../utils/chai-chain-utils'
+import finale from '../utils/finale'
+import { removeDefaultImport } from '../utils/imports'
+import { findParentOfType } from '../utils/recast-helpers'
+import {
+  expressionContainsProperty,
+  getExpectArg,
+  isExpectSinonCall,
+  isExpectSinonObject,
+  modifyVariableDeclaration,
+} from '../utils/sinon-helpers'
+
+const SINON_CALL_COUNT_METHODS = [
+  'called',
+  'calledOnce',
+  'calledTwice',
+  'calledThrice',
+  'callCount',
+  'notCalled',
+]
+const CHAI_CHAIN_MATCHERS = new Set(
+  ['be', 'eq', 'eql', 'equal', 'toBe', 'toEqual', 'toBeTruthy', 'toBeFalsy'].map((a) =>
+    a.toLowerCase()
+  )
+)
+const SINON_CALLED_WITH_METHODS = ['calledWith', 'notCalledWith']
+const SINON_SPY_METHODS = ['spy', 'stub']
+const SINON_MOCK_RESETS = {
+  reset: 'mockReset',
+  restore: 'mockRestore',
+}
+const SINON_MATCHERS = {
+  array: 'Array',
+  func: 'Function',
+  number: 'Number',
+  object: 'Object',
+  string: 'String',
+}
+const SINON_MATCHERS_WITH_ARGS = {
+  array: 'object',
+  func: 'function',
+  number: 'number',
+  object: 'object',
+  string: 'string',
+}
+const SINON_NTH_CALLS = new Set(['firstCall', 'secondCall', 'thirdCall', 'lastCall'])
+const EXPECT_PREFIXES = new Set(['to'])
+
+/* 
+  expect(spy.called).to.be(true) -> expect(spy).toHaveBeenCalled()
+  expect(spy.callCount).to.equal(2) -> expect(spy).toHaveBeenCalledTimes(2)
+*/
+function transformCallCountAssertions(j, ast) {
+  const chainContains = chainContainsUtil(j)
+  const getAllBefore = getNodeBeforeMemberExpressionUtil(j)
+  const createCall = createCallUtil(j)
+
+  ast
+    .find(j.CallExpression, {
+      callee: {
+        type: j.MemberExpression.name,
+        property: {
+          name: (name) => CHAI_CHAIN_MATCHERS.has(name.toLowerCase?.()),
+        },
+        object: (node) =>
+          isExpectSinonObject(node, SINON_CALL_COUNT_METHODS) &&
+          isExpectCallUtil(j, node),
+      },
+    })
+    .replaceWith((np) => {
+      const { node } = np
+      const expectArg = getExpectArg(node.callee)
+
+      // remove .called/.callCount/etc prop from expect argument
+      // eg: expect(Api.get.callCount) -> expect(Api.get)
+      j(np)
+        .find(j.CallExpression, {
+          callee: { name: 'expect' },
+        })
+        .forEach((np) => {
+          np.node.arguments = [expectArg.object]
+        })
+
+      /* 
+        handle  `expect(spy.withArgs('foo').called).to.be(true)` ->
+                `expect(spy.calledWith(1,2,3)).to.be(true)`
+        and let subsequent transform fn take care of converting to
+        the final form (ie: see `transformCalledWithAssertions`) 
+      */
+      if (expectArg.object.callee?.property?.name === 'withArgs') {
+        // change .withArgs() -> .calledWith()
+        expectArg.object.callee.property.name = 'calledWith'
+        return node
+      }
+
+      const expectArgSinonMethod = expectArg.property.name
+
+      const isPrefix = (name) => EXPECT_PREFIXES.has(name)
+      const negated =
+        chainContains('not', node.callee, isPrefix) || node.arguments?.[0].value === false // eg: .to.be(false)
+      const rest = getAllBefore(isPrefix, node.callee, 'should')
+
+      switch (expectArgSinonMethod) {
+        case 'notCalled':
+          return createCall('toHaveBeenCalled', [], rest, !negated)
+        case 'calledTwice':
+          return createCall('toHaveBeenCalledTimes', [j.literal(2)], rest, negated)
+        case 'calledOnce':
+          return createCall('toHaveBeenCalledTimes', [j.literal(1)], rest, negated)
+        case 'called':
+        case 'calledThrice':
+          return createCall('toHaveBeenCalled', [], rest, negated)
+        default:
+          // eg: .callCount
+          return createCall(
+            'toHaveBeenCalledTimes',
+            node.arguments.length ? [node.arguments[0]] : [],
+            rest,
+            negated
+          )
+      }
+    })
+}
+
+/* 
+  expect(spy.calledWith(1, 2, 3)).to.be(true) -> expect(spy).toHaveBeenCalledWith(1, 2, 3);
+
+  https://github.com/jordalgo/jest-codemods/blob/7de97c1d0370c7915cf5e5cc2a860bc5dd96744b/src/transformers/sinon.js#L267
+*/
+function transformCalledWithAssertions(j, ast) {
+  const chainContains = chainContainsUtil(j)
+  const getAllBefore = getNodeBeforeMemberExpressionUtil(j)
+  const createCall = createCallUtil(j)
+
+  ast
+    .find(j.CallExpression, {
+      callee: {
+        type: j.MemberExpression.name,
+        property: {
+          name: (name) => CHAI_CHAIN_MATCHERS.has(name.toLowerCase?.()),
+        },
+        object: (node) =>
+          isExpectSinonCall(node, SINON_CALLED_WITH_METHODS) && isExpectCallUtil(j, node),
+      },
+    })
+    .replaceWith((np) => {
+      const { node } = np
+      const expectArg = getExpectArg(node.callee)
+
+      // remove .calledWith() call from expect argument
+      j(np)
+        .find(j.CallExpression, {
+          callee: { name: 'expect' },
+        })
+        .forEach((np) => {
+          np.node.arguments = [expectArg.callee.object]
+        })
+
+      const expectArgSinonMethod = expectArg.callee?.property?.name
+      const isPrefix = (name) => EXPECT_PREFIXES.has(name)
+      const negated =
+        chainContains('not', node.callee, isPrefix) || node.arguments?.[0].value === false // eg: .to.be(false)
+      const rest = getAllBefore(isPrefix, node.callee, 'should')
+
+      switch (expectArgSinonMethod) {
+        case 'calledWith':
+          return createCall('toHaveBeenCalledWith', expectArg.arguments, rest, negated)
+        case 'notCalledWith':
+          return createCall('toHaveBeenCalledWith', expectArg.arguments, rest, !negated)
+        default:
+          return node
+      }
+    })
+}
+
+/* 
+sinon.stub(Api, 'get') -> jest.spyOn(Api, 'get')
+*/
+function transformStub(j: core.JSCodeshift, ast, sinonExpression) {
+  ast
+    .find(j.CallExpression, {
+      callee: {
+        type: 'MemberExpression',
+        property: {
+          type: 'Identifier',
+          name: (name) => SINON_SPY_METHODS.includes(name),
+        },
+        object: {
+          type: 'Identifier',
+          name: sinonExpression,
+        },
+      },
+    })
+    .replaceWith((np) => {
+      const args = np.value.arguments
+
+      // stubbing/spyOn module
+      if (args.length >= 2) {
+        let spyOn = j.callExpression(
+          j.memberExpression(j.identifier('jest'), j.identifier('spyOn')),
+          args.slice(0, 2)
+        )
+
+        // add mockClear since jest doesn't reset the stub on re-declaration like sinon does
+        spyOn = j.callExpression(j.memberExpression(spyOn, j.identifier('mockClear')), [])
+
+        // add mockImplementation call
+        if (args.length === 3) {
+          spyOn = j.callExpression(
+            j.memberExpression(spyOn, j.identifier('mockImplementation')),
+            [args[2]]
+          )
+        }
+
+        return spyOn
+      }
+
+      const jestFnCall = j.callExpression(j.identifier('jest.fn'), [])
+
+      if (args.length === 1) {
+        return j.callExpression(
+          j.memberExpression(jestFnCall, j.identifier('mockImplementation')),
+          args
+        )
+      }
+
+      // jest mock function
+      return jestFnCall
+    })
+}
+
+/*
+  stub.getCall(0) -> stub.mock.calls[0]
+  stub.getCall(0).args[1] -> stub.mock.calls[0][1]
+  stub.firstCall|lastCall|thirdCall|secondCall -> stub.mock.calls[n]
+*/
+function transformStubGetCalls(j: core.JSCodeshift, ast) {
+  // transform .getCall
+  ast
+    .find(j.CallExpression, {
+      callee: {
+        property: {
+          name: (n) => ['getCall', 'getCalls'].includes(n),
+        },
+      },
+    })
+    .replaceWith((np) => {
+      const { node } = np
+      const withMockCall = j.memberExpression(
+        j.memberExpression(node.callee.object, j.identifier('mock')),
+        j.identifier('calls')
+      )
+      if (node.callee.property.name === 'getCall') {
+        return j.memberExpression(
+          withMockCall,
+          // ensure is a literal to prevent something like: `calls.0[0]`
+          j.literal(node.arguments?.[0]?.value ?? 0)
+        )
+      }
+      return withMockCall
+    })
+
+  // transform .nthCall
+  ast
+    .find(j.MemberExpression, {
+      property: {
+        name: (name) => SINON_NTH_CALLS.has(name),
+      },
+    })
+    .replaceWith((np) => {
+      const { node } = np
+      const { name } = node.property
+
+      const createMockCall = (n) => {
+        const nth = j.literal(n)
+        return j.memberExpression(j.memberExpression(node, j.identifier('calls')), nth)
+      }
+
+      node.property.name = 'mock'
+      switch (name) {
+        case 'firstCall':
+          return createMockCall(0)
+        case 'secondCall':
+          return createMockCall(1)
+        case 'thirdCall':
+          return createMockCall(2)
+        case 'lastCall': {
+          return j.memberExpression(node, j.identifier('lastCall'))
+        }
+      }
+      return node
+    })
+
+  // transform .args[n] expression
+  ast
+    // match on .args, not the more specific .args[n]
+    .find(j.MemberExpression, {
+      property: {
+        name: 'args',
+      },
+    })
+    .replaceWith((np) => {
+      const { node } = np
+
+      // if contains .mock.calls already, can safely remove .args
+      if (
+        expressionContainsProperty(node, 'mock') &&
+        (expressionContainsProperty(node, 'calls') ||
+          expressionContainsProperty(node, 'lastCall'))
+      ) {
+        return np.node.object
+      }
+
+      /* 
+        replace .args with mock.calls, handles:
+        stub.args[0][0] -> stub.mock.calls[0][0]
+      */
+      return j.memberExpression(np.node.object, j.identifier('mock.calls'))
+    })
+}
+
+/* 
+  handles:
+    .withArgs
+    .returns
+    .returnsArg
+*/
+function transformMock(j: core.JSCodeshift, ast) {
+  // stub.withArgs(111).returns('foo') => stub.mockImplementation((...args) => { if (args[0] === '111') return 'foo' })
+  ast
+    .find(j.CallExpression, {
+      callee: {
+        object: {
+          callee: {
+            property: {
+              name: 'withArgs',
+            },
+          },
+        },
+        property: { name: 'returns' },
+      },
+    })
+    .replaceWith((np) => {
+      const { node } = np
+
+      // `jest.spyOn` or `jest.fn`
+      const mockFn = node.callee.object.callee.object
+      const mockImplementationArgs = node.callee.object.arguments
+      const mockImplementationReturn = node.arguments
+
+      // unsupported/untransformable .withArgs, just remove .withArgs from chain
+      if (!mockImplementationArgs?.length || !mockImplementationReturn?.length) {
+        node.callee = j.memberExpression(mockFn, node.callee.property)
+        return node
+      }
+
+      const isSinonMatcherArg = (arg) =>
+        arg.type === 'MemberExpression' &&
+        arg.object?.object?.name === 'sinon' &&
+        arg.object?.property?.name === 'match'
+
+      // generate conditional expression to match args used in .mockImplementation
+      const mockImplementationConditionalExpression = (mockImplementationArgs as any[])
+        .map((arg, i) => {
+          const argName = j.identifier(`args[${i}]`)
+          // handle sinon matchers
+          if (isSinonMatcherArg(arg)) {
+            const matcherType = SINON_MATCHERS_WITH_ARGS[arg.property.name]
+            // `sinon.match.object` -> `typeof args[0] === 'object'`
+            if (matcherType) {
+              return j.binaryExpression(
+                '===',
+                j.unaryExpression('typeof', argName),
+                j.stringLiteral(matcherType)
+              )
+            }
+            // handle `sinon.match.any` - check for total number of args, eg: `args.length >= ${expectedArgs}
+            return j.binaryExpression(
+              '>=',
+              j.memberExpression(j.identifier('args'), j.identifier('length')),
+              j.literal(mockImplementationArgs.length)
+            )
+          }
+          return j.binaryExpression('===', argName, arg)
+        })
+        .reduce((logicalExp: any, binExp: any, i) => {
+          if (i === 0) {
+            return binExp
+          }
+          return j.logicalExpression('&&', logicalExp, binExp)
+        })
+
+      const mockImplementationFn = j.arrowFunctionExpression(
+        [j.spreadPropertyPattern(j.identifier('args'))],
+        j.blockStatement([
+          j.ifStatement(
+            mockImplementationConditionalExpression,
+            j.returnStatement(mockImplementationReturn[0])
+          ),
+        ])
+      )
+
+      // `jest.fn` or `jest.spyOn`
+      return j.callExpression(
+        j.memberExpression(mockFn, j.identifier('mockImplementation')),
+        [mockImplementationFn]
+      )
+    })
+
+  // any remaining `.returns()` -> `.mockReturnValue()`
+  ast
+    .find(j.CallExpression, {
+      callee: {
+        type: 'MemberExpression',
+        property: { type: 'Identifier', name: 'returns' },
+      },
+    })
+    .forEach((np) => {
+      np.node.callee.property.name = 'mockReturnValue'
+    })
+
+  // .returnsArg
+  ast
+    .find(j.CallExpression, {
+      callee: {
+        type: 'MemberExpression',
+        property: { name: 'returnsArg' },
+      },
+    })
+    .replaceWith((np) => {
+      const { node } = np
+      node.callee.property.name = 'mockImplementation'
+      const argToMock = j.literal(node.arguments[0].value)
+
+      const argsVar = j.identifier('args')
+      const mockImplementationFn = j.arrowFunctionExpression(
+        [j.spreadPropertyPattern(argsVar)],
+        j.memberExpression(argsVar, argToMock)
+      )
+      node.arguments = [mockImplementationFn]
+      return node
+    })
+}
+
+/* 
+  handles mock resets/clears/etc:
+  sinon.restore() -> jest.restoreAllMocks()
+  stub.restore() -> stub.mockRestore()
+  stub.reset() -> stub.mockReset()
+*/
+function transformMockResets(j, ast) {
+  ast
+    .find(j.CallExpression, {
+      callee: {
+        type: 'MemberExpression',
+        object: {
+          type: 'Identifier',
+          name: 'sinon',
+        },
+        property: {
+          type: 'Identifier',
+          name: 'restore',
+        },
+      },
+    })
+    .forEach((np) => {
+      np.node.callee.object.name = 'jest'
+      np.node.callee.property.name = 'restoreAllMocks'
+    })
+
+  ast
+    .find(j.CallExpression, {
+      callee: {
+        type: 'MemberExpression',
+        property: {
+          type: 'Identifier',
+          name: (name) => name in SINON_MOCK_RESETS,
+        },
+      },
+    })
+    .forEach((np) => {
+      const name = SINON_MOCK_RESETS[np.node.callee.property.name]
+      np.node.callee.property.name = name
+    })
+}
+
+/* 
+  sinon.match({ ... }) -> expect.objectContaining({ ... })
+  // .any. matches:
+  sinon.match.[any|number|string|object|func|array] -> expect.any(type)
+*/
+function transformMatch(j, ast) {
+  ast
+    .find(j.CallExpression, {
+      callee: {
+        type: 'MemberExpression',
+        object: {
+          type: 'Identifier',
+          name: 'sinon',
+        },
+        property: {
+          type: 'Identifier',
+          name: 'match',
+        },
+      },
+    })
+    .replaceWith((np) => {
+      const args = np.node.arguments
+      return j.callExpression(j.identifier('expect.objectContaining'), args)
+    })
+
+  ast
+    .find(j.MemberExpression, {
+      type: 'MemberExpression',
+      object: {
+        object: {
+          name: 'sinon',
+        },
+        property: {
+          name: 'match',
+        },
+      },
+    })
+    .replaceWith((np) => {
+      const { name } = np.node.property
+      const constructorType = SINON_MATCHERS[name]
+      if (constructorType) {
+        return j.callExpression(j.identifier('expect.any'), [
+          j.identifier(constructorType),
+        ])
+      }
+      return j.callExpression(j.identifier('expect.anything'), [])
+    })
+}
+
+function transformMockTimers(j, ast) {
+  // sinon.useFakeTimers() -> jest.useFakeTimers()
+  // sinon.useFakeTimers(new Date(...)) -> jest.useFakeTimers().setSystemTime(new Date(...))
+  ast
+    .find(j.CallExpression, {
+      callee: {
+        object: {
+          name: 'sinon',
+        },
+        property: {
+          name: 'useFakeTimers',
+        },
+      },
+    })
+    .forEach((np) => {
+      let { node } = np
+      node.callee.object.name = 'jest'
+
+      // handle real system time
+      if (node.arguments?.length) {
+        const args = node.arguments
+        node.arguments = []
+        node = j.callExpression(
+          j.memberExpression(node, j.identifier('setSystemTime')),
+          args
+        )
+      }
+
+      // if `const clock = sinon.useFakeTimers()`, remove variable dec
+      const parentAssignment =
+        findParentOfType(np, j.VariableDeclaration.name) ||
+        findParentOfType(np, j.AssignmentExpression.name)
+
+      if (parentAssignment) {
+        // clock = sinon.useFakeTimers()
+        if (parentAssignment.value?.type === j.AssignmentExpression.name) {
+          const varName = parentAssignment.value.left?.name
+
+          // clock = sinon.useFakeTimers() -> sinon.useFakeTimers()
+          parentAssignment.parentPath.value.expression = node
+
+          // remove global variable declaration
+          const varNp = np.scope.lookup(varName)?.getBindings()?.[varName]?.[0]
+          if (varNp) {
+            modifyVariableDeclaration(varNp, null)
+          }
+
+          // const clock = sinon.useFakeTimers() -> sinon.useFakeTimers()
+        } else if (parentAssignment.parentPath.name === 'body') {
+          modifyVariableDeclaration(np, j.expressionStatement(node))
+        }
+      }
+    })
+
+  // clock.tick(n) -> jest.advanceTimersByTime(n)
+  ast
+    .find(j.CallExpression, {
+      callee: {
+        object: {
+          type: 'Identifier',
+        },
+        property: {
+          name: 'tick',
+        },
+      },
+    })
+    .forEach((np) => {
+      const { node } = np
+      node.callee.object.name = 'jest'
+      node.callee.property.name = 'advanceTimersByTime'
+    })
+
+  /* 
+    `stub.restore` shares the same property name as `sinon.useFakeTimers().restore`
+    so only transform those with `clock` object which seems to be the common name used
+    for mock timers throughout our codebase
+  */
+  // clock.restore() -> jest.useRealTimers()
+  ast
+    .find(j.CallExpression, {
+      callee: {
+        object: {
+          name: 'clock',
+        },
+        property: {
+          name: 'restore',
+        },
+      },
+    })
+    .forEach((np) => {
+      const { node } = np
+      node.callee.object.name = 'jest'
+      node.callee.property.name = 'useRealTimers'
+    })
+}
+
+export default function transformer(fileInfo: FileInfo, api: API, options) {
+  const j = api.jscodeshift
+  const ast = j(fileInfo.source)
+
+  const sinonExpression = removeDefaultImport(j, ast, 'sinon-sandbox')
+
+  if (!sinonExpression) {
+    console.warn(`no sinon for "${fileInfo.path}"`)
+    if (!options.skipImportDetection) {
+      return fileInfo.source
+    }
+    return null
+  }
+
+  transformStub(j, ast, sinonExpression)
+  transformMockTimers(j, ast)
+  transformMock(j, ast)
+  transformMockResets(j, ast)
+  transformCallCountAssertions(j, ast)
+  transformCalledWithAssertions(j, ast)
+  transformMatch(j, ast)
+  transformStubGetCalls(j, ast)
+
+  return finale(fileInfo, j, ast, options)
+}
diff --git a/src/utils/chai-chain-utils.ts b/src/utils/chai-chain-utils.ts
index 738969b..56aa051 100644
--- a/src/utils/chai-chain-utils.ts
+++ b/src/utils/chai-chain-utils.ts
@@ -100,3 +100,8 @@ export const createCallChainUtil = (j) => (chain, args) => {
 
   return j.callExpression(curr, args)
 }
+
+export const isExpectCallUtil = (j, node) =>
+  node.name === 'expect' ||
+  (node.type === j.MemberExpression.name && isExpectCallUtil(j, node.object)) ||
+  (node.type === j.CallExpression.name && isExpectCallUtil(j, node.callee))
diff --git a/src/utils/finale.ts b/src/utils/finale.ts
index 9a4dd65..1add176 100644
--- a/src/utils/finale.ts
+++ b/src/utils/finale.ts
@@ -10,7 +10,14 @@ import detectQuoteStyle from './quote-style'
 function detectIncompatiblePackages(fileInfo, j, ast) {
   ;['sinon', 'testdouble'].forEach((pkg) => {
     if (hasRequireOrImport(j, ast, pkg)) {
-      logger(fileInfo, `Usage of package "${pkg}" might be incompatible with Jest`)
+      const msg = `Usage of package "${pkg}" might be incompatible with Jest`
+      if (pkg === 'sinon') {
+        return logger(
+          fileInfo,
+          `${msg}; it's recommended the sinon transformer is run first`
+        )
+      }
+      logger(fileInfo, msg)
     }
   })
 }
diff --git a/src/utils/sinon-helpers.ts b/src/utils/sinon-helpers.ts
new file mode 100644
index 0000000..6a55e6b
--- /dev/null
+++ b/src/utils/sinon-helpers.ts
@@ -0,0 +1,61 @@
+import { findParentOfType } from './recast-helpers'
+
+export function isExpectSinonCall(obj, sinonMethods) {
+  if (obj.type === 'CallExpression' && obj.callee.name === 'expect') {
+    const args = obj.arguments
+    if (args.length) {
+      return (
+        args[0].type === 'CallExpression' &&
+        args[0].callee.type === 'MemberExpression' &&
+        sinonMethods.includes(args[0].callee.property.name)
+      )
+    }
+    return false
+  } else if (obj.type === 'MemberExpression') {
+    return isExpectSinonCall(obj.object, sinonMethods)
+  }
+}
+
+export function isExpectSinonObject(obj, sinonMethods) {
+  if (obj.type === 'CallExpression' && obj.callee.name === 'expect') {
+    const args = obj.arguments
+    if (args.length) {
+      return (
+        args[0].type === 'MemberExpression' &&
+        sinonMethods.includes(args[0].property.name)
+      )
+    }
+    return false
+  } else if (obj.type === 'MemberExpression') {
+    return isExpectSinonObject(obj.object, sinonMethods)
+  }
+}
+
+export function getExpectArg(obj) {
+  if (obj.type === 'MemberExpression') {
+    return getExpectArg(obj.object)
+  } else {
+    return obj.arguments[0]
+  }
+}
+
+export function modifyVariableDeclaration(nodePath, newNodePath) {
+  const varDec = findParentOfType(nodePath, 'VariableDeclaration')
+  if (!varDec) return
+  varDec.parentPath?.value?.forEach?.((n, i) => {
+    if (varDec.value === n) {
+      varDec.parentPath.value[i] = newNodePath
+    }
+  })
+}
+
+export function expressionContainsProperty(node, memberName) {
+  let current = node
+  while (current) {
+    if (current.property?.name === memberName) {
+      return true
+    }
+    current = current.object
+  }
+  return false
+}


Test patch:
diff --git a/src/transformers/sinon.test.ts b/src/transformers/sinon.test.ts
new file mode 100644
index 0000000..54d74c6
--- /dev/null
+++ b/src/transformers/sinon.test.ts
@@ -0,0 +1,470 @@
+/* eslint-env jest */
+import chalk from 'chalk'
+
+import { wrapPlugin } from '../utils/test-helpers'
+import plugin from './sinon'
+
+chalk.level = 0
+
+const wrappedPlugin = wrapPlugin(plugin)
+beforeEach(() => {
+  jest.spyOn(console, 'warn').mockImplementation().mockClear()
+})
+
+function expectTransformation(source, expectedOutput, options = {}) {
+  const result = wrappedPlugin(source, options)
+  expect(result).toBe(expectedOutput)
+  expect(console.warn).toBeCalledTimes(0)
+}
+
+it('removes imports', () => {
+  expectTransformation(
+    `
+      import foo from 'foo'
+      import sinon from 'sinon-sandbox';
+`,
+    `
+      import foo from 'foo'
+`
+  )
+})
+
+describe('spies and stubs', () => {
+  it('handles spies', () => {
+    expectTransformation(
+      `
+        import sinon from 'sinon-sandbox'
+        const stub = sinon.stub(Api, 'get')
+        sinon.stub(I18n, 'extend');
+        sinon.stub(AirbnbUser, 'current').returns(currentUser);
+        sinon.spy(I18n, 'extend');
+        sinon.spy();
+        sinon.spy(() => 'foo');
+`,
+      `
+        const stub = jest.spyOn(Api, 'get').mockClear()
+        jest.spyOn(I18n, 'extend').mockClear();
+        jest.spyOn(AirbnbUser, 'current').mockClear().mockReturnValue(currentUser);
+        jest.spyOn(I18n, 'extend').mockClear();
+        jest.fn();
+        jest.fn().mockImplementation(() => 'foo');
+`
+    )
+  })
+
+  it('handles 3rd argument implementation fn', () => {
+    expectTransformation(
+      `
+        import sinon from 'sinon-sandbox'
+        sinon.stub(I18n, 'extend', () => 'foo');
+`,
+      `
+        jest.spyOn(I18n, 'extend').mockClear().mockImplementation(() => 'foo');
+`
+    )
+  })
+
+  it('mock clear if spy added in beforeEach', () => {
+    expectTransformation(
+      `
+        import sinon from 'sinon-sandbox'
+
+        beforeEach(() => {
+          sinon.stub(Api, 'get')
+          const s1 = sinon.stub(I18n, 'extend')
+          const s2 = sinon.stub(I18n, 'extend').returns('en')
+          sinon.stub(L10n, 'language').returns('en')
+          sinon.stub(I18n, 'extend', () => 'foo');
+        })
+`,
+      `
+        beforeEach(() => {
+          jest.spyOn(Api, 'get').mockClear()
+          const s1 = jest.spyOn(I18n, 'extend').mockClear()
+          const s2 = jest.spyOn(I18n, 'extend').mockClear().mockReturnValue('en')
+          jest.spyOn(L10n, 'language').mockClear().mockReturnValue('en')
+          jest.spyOn(I18n, 'extend').mockClear().mockImplementation(() => 'foo');
+        })
+`
+    )
+  })
+
+  it('handles returns', () => {
+    expectTransformation(
+      `
+        import sinon from 'sinon-sandbox'
+        const stub1 = sinon.stub(Api, 'get').returns('foo')
+        const stub2 = sinon.stub(Api, 'get').returns(Promise.resolve({ foo: '1' }))
+`,
+      `
+        const stub1 = jest.spyOn(Api, 'get').mockClear().mockReturnValue('foo')
+        const stub2 = jest.spyOn(Api, 'get').mockClear().mockReturnValue(Promise.resolve({ foo: '1' }))
+`
+    )
+  })
+
+  it('handles .returnsArg', () => {
+    expectTransformation(
+      `
+        import sinon from 'sinon-sandbox'
+        sinon.stub(foo, 'getParam').returnsArg(3);
+  `,
+      `
+        jest.spyOn(foo, 'getParam').mockClear().mockImplementation((...args) => args[3]);
+  `
+    )
+  })
+
+  it('handles .withArgs returns', () => {
+    expectTransformation(
+      `
+        import sinon from 'sinon-sandbox'
+
+        sinon.stub().withArgs('foo').returns('something')
+        sinon.stub().withArgs('foo', 'bar').returns('something')
+        sinon.stub().withArgs('foo', 'bar', 1).returns('something')
+        sinon.stub(Api, 'get').withArgs('foo', 'bar', 1).returns('something')
+        const stub = sinon.stub(foo, 'bar').withArgs('foo', 1).returns('something')
+        sinon.stub(foo, 'bar').withArgs('foo', sinon.match.object).returns('something')
+        sinon.stub().withArgs('foo', sinon.match.any).returns('something')
+`,
+      `
+        jest.fn().mockImplementation((...args) => {
+                if (args[0] === 'foo')
+                        return 'something';
+        })
+        jest.fn().mockImplementation((...args) => {
+                if (args[0] === 'foo' && args[1] === 'bar')
+                        return 'something';
+        })
+        jest.fn().mockImplementation((...args) => {
+                if (args[0] === 'foo' && args[1] === 'bar' && args[2] === 1)
+                        return 'something';
+        })
+        jest.spyOn(Api, 'get').mockClear().mockImplementation((...args) => {
+                if (args[0] === 'foo' && args[1] === 'bar' && args[2] === 1)
+                        return 'something';
+        })
+        const stub = jest.spyOn(foo, 'bar').mockClear().mockImplementation((...args) => {
+                if (args[0] === 'foo' && args[1] === 1)
+                        return 'something';
+        })
+        jest.spyOn(foo, 'bar').mockClear().mockImplementation((...args) => {
+                if (args[0] === 'foo' && typeof args[1] === 'object')
+                        return 'something';
+        })
+        jest.fn().mockImplementation((...args) => {
+                if (args[0] === 'foo' && args.length >= 2)
+                        return 'something';
+        })
+`
+    )
+  })
+
+  /* 
+    apiStub.getCall(0).args[1].data
+    apistub.args[1][1]
+  */
+  it('handles .getCall, .getCalls and spy arguments', () => {
+    expectTransformation(
+      `
+        import sinon from 'sinon-sandbox'
+
+        apiStub.getCall(0)
+        apiStub.getCall(0).args[1].data
+        dispatch.getCall(0).args[0]
+        onPaginate.getCall(0).args
+        api.get.getCall(0).args[0][1]
+
+        api.getCalls()[2]
+        api.getCalls()[2].args
+`,
+      `
+        apiStub.mock.calls[0]
+        apiStub.mock.calls[0][1].data
+        dispatch.mock.calls[0][0]
+        onPaginate.mock.calls[0]
+        api.get.mock.calls[0][0][1]
+
+        api.mock.calls[2]
+        api.mock.calls[2]
+`
+    )
+  })
+
+  it('handles .args[n]', () => {
+    expectTransformation(
+      `
+        import sinon from 'sinon-sandbox'
+
+        apiStub.args[2][3]
+        apiStub.foo.bar.args[2][3]
+
+        // just remove .args
+        apiStub.mock.calls[0].args[3]
+`,
+      `
+        apiStub.mock.calls[2][3]
+        apiStub.foo.bar.mock.calls[2][3]
+
+        // just remove .args
+        apiStub.mock.calls[0][3]
+`
+    )
+  })
+
+  it('handles .nthCall', () => {
+    expectTransformation(
+      `
+        import sinon from 'sinon-sandbox'
+
+        apiStub.firstCall
+        apiStub.firstCall.args[1].data
+        apiStub.secondCall
+        apiStub.secondCall.args[1].data
+        apiStub.thirdCall
+        apiStub.thirdCall.args[1].data
+        apiStub.lastCall
+        apiStub.lastCall.args[1].data
+`,
+      `
+        apiStub.mock.calls[0]
+        apiStub.mock.calls[0][1].data
+        apiStub.mock.calls[1]
+        apiStub.mock.calls[1][1].data
+        apiStub.mock.calls[2]
+        apiStub.mock.calls[2][1].data
+        apiStub.mock.lastCall
+        apiStub.mock.lastCall[1].data
+`
+    )
+  })
+})
+
+describe('mocks', () => {
+  it('handles creating mocks', () => {
+    expectTransformation(
+      `
+        import sinon from 'sinon-sandbox'
+        const stub = sinon.stub()
+`,
+      `
+        const stub = jest.fn()
+`
+    )
+  })
+
+  it('handles resets/clears', () => {
+    expectTransformation(
+      `
+        import sinon from 'sinon-sandbox'
+        stub.restore()
+        Api.get.restore()
+        Api.get.reset()
+        sinon.restore()
+`,
+      `
+        stub.mockRestore()
+        Api.get.mockRestore()
+        Api.get.mockReset()
+        jest.restoreAllMocks()
+`
+    )
+  })
+})
+
+describe('sinon.match', () => {
+  it('handles creating mocks', () => {
+    expectTransformation(
+      `
+        import sinon from 'sinon-sandbox'
+
+        sinon.match({
+          foo: 'foo'
+        })
+        sinon.match({
+          foo: sinon.match({
+            bar: 'bar'
+          })
+        })
+        expect(foo).toEqual(sinon.match.number)
+        foo(sinon.match.number)
+        foo(sinon.match.string)
+        foo(sinon.match.object)
+        foo(sinon.match.func)
+        foo(sinon.match.array)
+        foo(sinon.match.any)
+`,
+      `
+        expect.objectContaining({
+          foo: 'foo'
+        })
+        expect.objectContaining({
+          foo: expect.objectContaining({
+            bar: 'bar'
+          })
+        })
+        expect(foo).toEqual(expect.any(Number))
+        foo(expect.any(Number))
+        foo(expect.any(String))
+        foo(expect.any(Object))
+        foo(expect.any(Function))
+        foo(expect.any(Array))
+        foo(expect.anything())
+`
+    )
+  })
+})
+
+describe('spy count and call assertions', () => {
+  it('handles call count assertions', () => {
+    expectTransformation(
+      `
+        import sinon from 'sinon-sandbox'
+
+        // basic cases
+        expect(Api.get.callCount).to.equal(1)
+        expect(spy.callCount).to.equal(1)
+
+        expect(Api.get.called).to.equal(true)
+        expect(spy.called).to.equal(true)
+        expect(Api.get.called).toEqual(true)
+        expect(spy.called).toEqual(true)
+
+        expect(spy.calledOnce).to.equal(true)
+        expect(spy.calledTwice).to.equal(true)
+        expect(spy.calledThrice).to.equal(true)
+        expect(spy.called).to.equal(true)
+
+        // .to.be
+        expect(Api.get.callCount).to.be(1)
+        expect(Api.get.called).to.be(true)
+        expect(Api.get.called).to.be(false)
+        expect(Api.get.callCount).toBe(1)
+        expect(Api.get.called).toBe(true)
+
+        // .not + neg cases
+        expect(Api.get.callCount).not.to.equal(1)
+        expect(spy.called).not.to.be(true)
+        expect(spy.callCount).not.to.be(1)
+        expect(spy.called).to.be(false)
+
+        // .notCalled cases
+        expect(spy.notCalled).to.equal(true)
+        expect(spy.notCalled).to.equal(false)
+`,
+      `
+        expect(Api.get).toHaveBeenCalledTimes(1);
+        expect(spy).toHaveBeenCalledTimes(1)
+
+        expect(Api.get).toHaveBeenCalled()
+        expect(spy).toHaveBeenCalled()
+        expect(Api.get).toHaveBeenCalled()
+        expect(spy).toHaveBeenCalled()
+
+        expect(spy).toHaveBeenCalledTimes(1)
+        expect(spy).toHaveBeenCalledTimes(2)
+        expect(spy).toHaveBeenCalled()
+        expect(spy).toHaveBeenCalled()
+
+        // .to.be
+        expect(Api.get).toHaveBeenCalledTimes(1)
+        expect(Api.get).toHaveBeenCalled()
+        expect(Api.get).not.toHaveBeenCalled()
+        expect(Api.get).toHaveBeenCalledTimes(1)
+        expect(Api.get).toHaveBeenCalled()
+
+        // .not + neg cases
+        expect(Api.get).not.toHaveBeenCalledTimes(1)
+        expect(spy).not.toHaveBeenCalled()
+        expect(spy).not.toHaveBeenCalledTimes(1)
+        expect(spy).not.toHaveBeenCalled()
+
+        // .notCalled cases
+        expect(spy).not.toHaveBeenCalled()
+        expect(spy).toHaveBeenCalled()
+`
+    )
+  })
+
+  it('handles call counts with args', () => {
+    expectTransformation(
+      `
+        import sinon from 'sinon-sandbox'
+        expect(spy.withArgs('foo', bar).called).to.be(true)
+        expect(spy.withArgs('foo', bar).called).to.be(false)
+`,
+      `
+        expect(spy).toHaveBeenCalledWith('foo', bar)
+        expect(spy).not.toHaveBeenCalledWith('foo', bar)
+`
+    )
+  })
+
+  it('handles calledWith', () => {
+    expectTransformation(
+      `
+        import sinon from 'sinon-sandbox'
+        expect(spy.calledWith(1, 2, 3)).to.be(true)
+        expect(spy.notCalledWith(1, 2, 3)).to.be(true)
+        expect(spy.calledWith(foo, 'bar')).to.be(false)
+        expect(spy.notCalledWith(foo, 'bar')).to.be(false)
+`,
+      `
+        expect(spy).toHaveBeenCalledWith(1, 2, 3)
+        expect(spy).not.toHaveBeenCalledWith(1, 2, 3)
+        expect(spy).not.toHaveBeenCalledWith(foo, 'bar')
+        expect(spy).toHaveBeenCalledWith(foo, 'bar')
+`
+    )
+  })
+})
+
+describe('mock timers', () => {
+  it('handles timers', () => {
+    expectTransformation(
+      `
+        import sinon from 'sinon-sandbox'
+        sinon.useFakeTimers()
+        clock.restore()
+        clock.tick(5)
+
+        let clock1
+        beforeEach(() => {
+          foo()
+          clock1 = sinon.useFakeTimers()
+          bar()
+        })
+
+        foo()
+        const clock = sinon.useFakeTimers()
+        bar()
+
+        beforeEach(() => {
+          const clock2 = sinon.useFakeTimers(new Date(2015, 2, 14, 0, 0).getTime())
+          clock1 = sinon.useFakeTimers(new Date(2015, 2, 14, 0, 0).getTime())
+        })
+`,
+      `
+        jest.useFakeTimers()
+        jest.useRealTimers()
+        jest.advanceTimersByTime(5)
+
+        beforeEach(() => {
+          foo()
+          jest.useFakeTimers()
+          bar()
+        })
+
+        foo()
+        jest.useFakeTimers();
+        bar()
+
+        beforeEach(() => {
+          jest.useFakeTimers().setSystemTime(new Date(2015, 2, 14, 0, 0).getTime());
+          jest.useFakeTimers().setSystemTime(new Date(2015, 2, 14, 0, 0).getTime())
+        })
+`
+    )
+  })
+})
diff --git a/src/utils/finale.test.ts b/src/utils/finale.test.ts
index 4f26dc7..e1cf63e 100644
--- a/src/utils/finale.test.ts
+++ b/src/utils/finale.test.ts
@@ -133,7 +133,7 @@ testChanged(
   unsupportedExample,
   {},
   [
-    'jest-codemods warning: (test.js) Usage of package "sinon" might be incompatible with Jest',
+    'jest-codemods warning: (test.js) Usage of package "sinon" might be incompatible with Jest; it\'s recommended the sinon transformer is run first',
     'jest-codemods warning: (test.js) Usage of package "testdouble" might be incompatible with Jest',
   ]
 )