Search code examples
typescriptjestjssupertest

Mocking ES6 class method in Jest with TypeScript


I've been struggling with this for the past week, so I finally need to admit that I don't know why this is happening.

I'm using supertest with express and TypeScript to test the implementation of some of my API routes. Keep in mind, I'm actively opening a db connection and writing to the DB here, and not using mocks for these methods.

However, I have a dependency in some of my controllers that rely on a logger instance attached to each controller (which is just an ES6 class).

I'm trying to figure out why it's not mocking it properly with the code below:

utils/logger.ts:

class Logger {
  private filename: string

  public constructor(filename: string) {
    this.filename = filename
  }

  public debug(message: string) {
    console.log('debug:', message)
  }
}

controllers/user.ts

class UserController {
  public logger: any

  constructor() {
    this.logger = new Logger('UserController.ts')
  }

  async create(req: Request, res: Response) {
    this.logger.debug('creating') <-- THIS KEEPS ERRORING with: TypeError: Cannot read property 'logger' of undefined
    return res.status(200).end()
  }
}

export default new UserController()

server/routes/user.ts:

import users from '../controllers/user'

class UserRoutes {
  constructor(server: any) {
    server.post('/api/v1/users/create', users.create)
  }
}

export default UserRoutes

here is the app server I'm passing to supertest: server.ts:

import bodyParser from 'body-parser'
import express from 'express'
import database from '../server/db'
import Routes from '../server/routes'

class TestApp {
  public server: any
  public database: any

  constructor() {
    this.initDatabase()
    this.run()
  }

  public async run(): Promise<void> {
    this.server = express()
    this.middleware()
    this.routes()
  }

  private middleware(): void {
    this.server.use(bodyParser.json())
  }

 private routes(): void {
    new Routes(this.server)
  }

  public initDatabase(): void {
    this.database = new database()
  }

  public closeDatabase(): void {
    this.database.close()
  }
}

export default TestApp

And finally the actual test:

import express, { Application } from 'express'
import request from 'supertest'
import TestApp from './server'
let server: any

const initServer = () => {
  jest.mock('../server/controllers/user', () => {
    return jest.fn().mockImplementation(() => {
      return {
        logger: {
          debug: jest.fn()
        }
      }
    })
  })

  const testApp = new TestApp()
  const server: Application = testApp.server
  const exp = express()
  return exp.use(server)
}

beforeAll(async () => {
  server = initServer()
})

describe('POST /api/v1/users', () => {
  test('should create a new user', async () => {
    const res = await request(server)
      .post('/api/v1/users/create')
      .send({
        username: 'testing'
      })
      expect(res.status).toEqual(200)
  })
})

I've tried mocking both the instance of logger in the user controller AS WELL AS the actual logger.ts entire class, but nothing has been working so far. Here's the full stack trace:

  TypeError: Cannot read property 'logger' of undefined

  46 |     
> 47 |     this.logger.debug('creating user')
     |          ^
  48 |
  49 |     try {
  50 |       const user = await User.create({

  at src/server/controllers/user.ts:47:10
  at src/server/controllers/user.ts:8:71
  at __awaiter (src/server/controllers/user.ts:4:12)
  at create (src/server/controllers/user.ts:50:16)
  at Layer.handle [as handle_request] (node_modules/express/lib/router/layer.js:95:5)
  at next (node_modules/express/lib/router/route.js:137:13)
  at Route.dispatch (node_modules/express/lib/router/route.js:112:3)
  at Layer.handle [as handle_request] (node_modules/express/lib/router/layer.js:95:5)
  at node_modules/express/lib/router/index.js:281:22
  at Function.process_params (node_modules/express/lib/router/index.js:335:12)
  at next (node_modules/express/lib/router/index.js:275:10)
  at SessionStrategy.strategy.pass (node_modules/passport/lib/middleware/authenticate.js:343:9)
  at SessionStrategy.authenticate (node_modules/passport/lib/strategies/session.js:75:10)
  at attempt (node_modules/passport/lib/middleware/authenticate.js:366:16)
  at authenticate (node_modules/passport/lib/middleware/authenticate.js:367:7)
  at Layer.handle [as handle_request] (node_modules/express/lib/router/layer.js:95:5)
  at trim_prefix (node_modules/express/lib/router/index.js:317:13)
  at node_modules/express/lib/router/index.js:284:7
  at Function.process_params (node_modules/express/lib/router/index.js:335:12)
  at next (node_modules/express/lib/router/index.js:275:10)

  console.log src/server/controllers/user.ts:46

Why is this (the user controller instance) always undefined?


Solution

  • Here you lose the context:

    server/routes/user.ts
    
    server.post('/api/v1/users/create', users.create)
    

    There are a few options to avoid it

    1. Define the method as a property in your controller
    controllers/user.ts
    
    class UserController {
      // ...
      create = async (req: Request, res: Response) => {
        this.logger.debug('creating');
        return res.status(200).end();
      }
      // ...
    }
    
    1. Bind the method to the instance (it creates one more funtion)
    server/routes/user.ts
    
    class UserRoutes {
      constructor(server: any) {
        server.post('/api/v1/users/create', users.create.bind(users));
      }
    }
    
    
    1. Almost the same as previous one but with arrow function instead of bind
    server/routes/user.ts
    
    class UserRoutes {
      constructor(server: any) {
        server.post('/api/v1/users/create', (req, res) => users.create(req, res));
      }
    }