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

question: How do you test your services with dependency injection? #1055

Open
buronix opened this issue Mar 27, 2023 · 32 comments
Open

question: How do you test your services with dependency injection? #1055

buronix opened this issue Mar 27, 2023 · 32 comments
Labels
type: question Questions about the usage of the library.

Comments

@buronix
Copy link

buronix commented Mar 27, 2023

Im trying to test some global standalone classes injected in my code, Im new to Typedi, I really like it, but the lack of documentation is killing me.
My problem is mostly with testing, I see no example at all of how to test with typedi, not sure about you guys, but I just took as granted that some examples of how to test with jest (or whatever) will be present, and now I found that maybe I just lost a week cause there is no example at all.
How do you test your injected dependencies?
By the way, not as manually parametrized, pls, I saw a lot of this in huge amount of helping places, not sure why, but If I could send my classes instances as parameters I will not need dependency injection at all.

@buronix buronix added the type: question Questions about the usage of the library. label Mar 27, 2023
@telega
Copy link

telega commented Apr 3, 2023

One approach is to use Container.set and Container.get in your tests.

@Service()
class MyGlobalClass() {
  foo(){}
}

@Service()
class MyOtherClass( public global: MyGlobalClass) {}

Then in your test, you do

describe('MyOtherClass' , () => {
 it('tests the class', () =>{
 Container.set(MyGlobalClass, {  foo: jest.fn().mockReturnValue(true)}) 
 const otherClass = Container.get(MyOtherClass)
 expect(otherClass.global.foo()).toBe(true)
 })
})

@tomasz-szymanek
Copy link

tomasz-szymanek commented Apr 25, 2023

I did exactly that and suddenly it stopped working:

Container.set('example-api-client', new TestApiClient())
const service = Container.get(ServiceUsingExampleApiClient)

console.log(service.exampleApiClient) // original service, not TestApiClient one set using container

I cannot get it to work, and it doesn't have any sense whatsoever to stop working on me like that

@telega
Copy link

telega commented Apr 25, 2023

how are you injecting ExampleApiClient into ServiceUsingExampleApiClient ?

@attilaorosz
Copy link
Member

Could you create a repo with this? Its hard to see whats wrong without context.

@tomasz-szymanek
Copy link

I cannot publish code from my work, and this is not reproducible since it doesnt work in that one only case, for now I can say that I've tried using Service() via type, and named Service() syntax (Service('name-of-service')) along with @Inject('name-of-service') and it doesnt work in either way.

@tomasz-szymanek
Copy link

tomasz-szymanek commented Apr 26, 2023

import 'reflect-metadata'
import { Service } from 'typedi'

@Service('pocket-api-client')
export class PocketApiClient {
  call = async (...args: any[]): Promise<any> => {
    console.log('CALL ORIGINAL')
    return { json: async () => Promise.resolve({ data: 'example', id: '123' }), ok: true }
  }
}
import 'reflect-metadata'
import { Inject, Service } from 'typedi'
import { PocketApiClient } from '../../../../lib/api-client/pocket-api-client'

...

@Service()
export class AddPocketSectionItem {
  @Inject('pocket-api-client')
  public pocketApiClient: PocketApiClient
  constructor() {}
  
  ...
import 'reflect-metadata'
import { Container } from 'typedi'
import { AddPocketSectionItem } from '../services/request'

...

class TestApiClient {
  public call = async (...args) => {
    console.log('CALL TEST')
    return { json: async () => Promise.resolve({ data: 'example', id: '123' }), ok: true }
  }
}

...

describe('POCKET SERVICE add-pocket-section-item', () => {
  it('responds with data fetched from api service', async () => {
    Container.set('pocket-api-client', new TestApiClient())
    const service = Container.get(AddPocketSectionItem)
    
    console.log(service.pocketApiClient) // original pocketApiClient, not TestApiClient

What is funny, is that when I remove type from Inject() statement, it says:

ServiceNotFoundError: Service with "pocket-api-client" identifier was not found in the container. Register it before usage via explicitly calling the "Container.set" function or using the "@Service()" decorator.

Needless to say, I've tried using only Service() and type syntax, without names, and it doesn't work - injected value in AddPocketSectionItem is just undefined

@attilaorosz
Copy link
Member

It's weird because it works for me locally. Is there a chance your lib is in a monorepo?

@tomasz-szymanek
Copy link

Yes, but other apps are not using any DI containers. It stopped working when I added some new code (unrelated to this), using httpContext for acquiring some headers from each request, and setting getConfig() method in a container via Container.set('get-config', getConfig). It's tested and mockable

import { Config } from 'config'
import { Container } from 'typedi'
import { getConfig } from '../get-config'
import { mockedConfig } from './config-mock'

describe('getConfig method', () => {
  it('responds with generated config', async () => {
    const config = getConfig()

    expect(config).toEqual(mockedConfig)
  })

  it('responds with generated config while using default app name', async () => {
    const config = getConfig('moda')

    expect(config).toEqual(mockedConfig)
  })

  it('is injectable', async () => {
    Container.set('MOCK_GET_CONFIG', getConfig)
    const injectedConfig = Container.get('MOCK_GET_CONFIG') as () => Config
    const config = injectedConfig()

    expect(config).toEqual(mockedConfig)
  })
})

those tests are passing

@attilaorosz
Copy link
Member

The problem is the metadata storage of the DI, technically it's tied to your node_modules folder. If your app happens to be in a monorepo that has separate node_module instances for each part, you are kinda out of luck. The Container instance will be different for each. Could you verify if that's the case?

@tomasz-szymanek
Copy link

First of all, thanks for your help, it's really kind of you. I verified, my API only uses one exclusive node_modules.

@attilaorosz
Copy link
Member

What is funny, is that when I remove type from Inject() statement, it says:

That is expected, if you never reference the class that registers the token it won't be available in the container.

Needless to say, I've tried using only Service() and type syntax, without names, and it doesn't work - injected value in AddPocketSectionItem is just undefined

This is the more interesting part, because if the container is not able to find the token it should throw an error.
The troubling part is that if you mess up the mock registration in the test, it should return the original not undefined.

@tomasz-szymanek
Copy link

In test it returns mocked one, in service's body there is undefined, without error

@attilaorosz
Copy link
Member

Do you use @Inject or constructor injection?

@tomasz-szymanek
Copy link

describe('POCKET SERVICE add-pocket-section-item', () => {
  it('responds with data fetched from api service', async () => {
    Container.set(PocketApiClient, new TestApiClient())
    const service = Container.get(AddPocketSectionItem)
    const mockedApiClient = Container.get(PocketApiClient)

    console.log(service.pocketApiClient) 
    console.log(mockedApiClient)
console.log
    undefined

      at console.log (../../node_modules/@sentry/src/integrations/console.ts:51:1)

  console.log
    TestApiClient { call: [Function (anonymous)] }

      at console.log (../../node_modules/@sentry/src/integrations/console.ts:51:1)

@tomasz-szymanek
Copy link

Tried both, it still doesn't work (sorry, I got excited because test passed)

@attilaorosz
Copy link
Member

Keep in mind that with @Inject you won't be able to access your injected dependency in the constructor body, the injection is done lazily

@tomasz-szymanek
Copy link

tomasz-szymanek commented Apr 26, 2023

After using constructor injection (without @Inject() decorator) it just injects original dependency instead of mock:

Container.set(PocketApiClient, new TestApiClient())
    const service = Container.get(AddPocketSectionItem)
    const mockedApiClient = Container.get(PocketApiClient)

    console.log(service.pocketApiClient) // original pocketApiClient, not TestApiClient
    console.log(mockedApiClient)
console.log
    PocketApiClient { call: [Function (anonymous)] }

      at console.log (../../node_modules/@sentry/src/integrations/console.ts:51:1)

  console.log
    TestApiClient { call: [Function (anonymous)] }

      at console.log (../../node_modules/@sentry/src/integrations/console.ts:51:1)

@tomasz-szymanek
Copy link

@Service()
export class AddPocketSectionItem {
  constructor(public pocketApiClient: PocketApiClient) {}
@Service()
export class PocketApiClient {
call = async (...args: any[]): Promise<any> => {
  console.log('CALL ORIGINAL')
  return { json: async () => Promise.resolve({ data: 'example', id: '123' }), ok: true }
}
}

@attilaorosz
Copy link
Member

Do you clear your container after each test? It could be that the container instanciated your service already, so you setting the dependency of your service does nothing since Container.get will just get back the already created instance anyway.

@tomasz-szymanek
Copy link

That ought to be the case here! How do I clear container? I searched in documentation, and I didn't see anything like "reset"

@attilaorosz
Copy link
Member

Container.reset() should do it for you

@tomasz-szymanek
Copy link

It doesn't work unfortunately, still getting original service instead of mocked one

@attilaorosz
Copy link
Member

Just for the sake of the test, could you remove the @Service decorator from PocketApiClient and see what happens?

@tomasz-szymanek
Copy link

ServiceNotFoundError: Service with "MaybeConstructable<PocketApiClient>" identifier was not found in the container. Register it before usage via explicitly calling the "Container.set" function or using the "@Service()" decorator.

@attilaorosz
Copy link
Member

Ok at this point all I can think of is that something else creates that service for you beforehand somewhere. Could you try to reset the container in the test itself before setting the mock?

@tomasz-szymanek
Copy link

tomasz-szymanek commented Apr 26, 2023

Yeah, I've tried that with the same effect. No worries mate, I'm done for today anyways, tomorrow I will try some other ideas

Thank you so much

@attilaorosz
Copy link
Member

You could add a log before the Container.get to make sure it's actually throwing there not beforehand

@tomasz-szymanek
Copy link

describe('POCKET SERVICE add-pocket-section-item', () => {
  it('responds with data fetched from api service', async () => {
    Container.reset()
    Container.set(PocketApiClient, new TestApiClient())
    const service = Container.get(AddPocketSectionItem)
    console.log(service.pocketApiClient)
    
    const mockedApiClient = Container.get(PocketApiClient)
    console.log(mockedApiClient)

its respectively undefined and TestApiClient { call: [Function (anonymous)] }

@attilaorosz
Copy link
Member

pinging @NoNameProvided for additional ideas

@telega
Copy link

telega commented Apr 27, 2023

maybe as an experiment try changing the implementation of AddPocketSectionItem to

export class AddPocketSectionItem {
  public pocketApiClient = Container.get(PocketApiClient)
  constructor() {}
 

@abhayshiravanthe
Copy link

See this
and also, remove all manual mocks of injected dependencies in the test code.
When asserting these injected class's methods in Jest tests, always use jest.spyOn instead of mocking them.

@Edkiri
Copy link

Edkiri commented Aug 16, 2023

One approach is to use Container.set and Container.get in your tests.

@Service()
class MyGlobalClass() {
  foo(){}
}

@Service()
class MyOtherClass( public global: MyGlobalClass) {}

Then in your test, you do

describe('MyOtherClass' , () => {
 it('tests the class', () =>{
 Container.set(MyGlobalClass, {  foo: jest.fn().mockReturnValue(true)}) 
 const otherClass = Container.get(MyOtherClass)
 expect(otherClass.global.foo()).toBe(true)
 })
})

It works for me!
I was also missing the import 'reflect-metadata' in my test file.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
type: question Questions about the usage of the library.
Development

No branches or pull requests

6 participants