#Proper way to mock a module

1 messages · Page 1 of 1 (latest)

ocean sparrow
#

Hello,

Here is my code:

import { Test } from '@nestjs/testing';

import { TrendsService } from './platform-service';
import { createMock } from '@golevelup/ts-jest';

describe('PlatformController', () => {
  beforeEach(async () => {
    const module = await Test.createTestingModule({
      imports: [],
      controllers: [],
      providers: [
        { provide: TrendsService, useValue: createMock<TrendsService>() },
      ],
    }).compile();
  });

  describe('getTrendsAvailablePeriods', () => {
    it('should call computeAvailableTrendPeriods with the correct account slug', async () => {
      expect(true).toBe(true);
    });
  });
});


// Trends.service.ts
@Injectable()
export class TrendsService {
  constructor(
    private readonly bigQueryService: BigQueryService,
    @Inject(CACHE_MANAGER) protected readonly cacheManager: Cache
  ) {}
...

As you can see, my test is trying to mock TrendsService. But just this small code is causing an error, because TrendsService constructor is calling BigQueryService which require custom env variable that I don't provide in my test environment. But it's not the issue, because I obviously don't want to do any BigQuery request in my tests.

My question is: shouldn't TrendsService be mocked here? Why is it being instantiated?

    const module = await Test.createTestingModule({
      imports: [],
      controllers: [],
      providers: [
        {
          provide: TrendsService,
          useValue: {
            test: 'zzz',
          },
        },
      ],
    }).compile();
  });

This code doesn't work either

Thanks a lot for your help!

wide nova
#

Are you certain that it's in this test that it is being instantiated?

ocean sparrow
#

Yes, this test:

import { Test } from '@nestjs/testing';

describe('PlatformController', () => {
  beforeEach(async () => {
    const module = await Test.createTestingModule({
      imports: [],
      controllers: [],
      providers: [],
    }).compile();
  });

  describe('getTrendsAvailablePeriods', () => {
    it('should call computeAvailableTrendPeriods with the correct account slug', async () => {
      expect(true).toBe(true);
    });
  });
});

Works well.
In fact TrendsService is not instantiated (constructor is not being called), it injects BigQueryService, from the BigQueryModule instantiated as followed:

import { Module } from '@nestjs/common';

import { getGcpServiceAccountCredentials } from 'src/utils/gcp';
import { createProvider } from 'src/utils/providers';

import { BigQueryService } from './bigquery.service';

@Module({
  providers: [
    createProvider('KAYA_DATASET_ID', process.env.KAYA_DATASET_ID || 'kaya'),
    createProvider('PROJECT_ID', 'xgsheet'),
    createProvider(
      'CREDENTIALS',
      getGcpServiceAccountCredentials('NIKLAS_API')
    ),
    createProvider('SCOPES', [
      'https://www.googleapis.com/auth/cloud-platform',
    ]),
    BigQueryService,
  ],
  exports: [BigQueryService],
})
export class BigQueryModule {}

And the function failing is getGcpServiceAccountCredentials('NIKLAS_API') I don't understand why this function is being called

wide nova
#

Can you share the full error message?

ocean sparrow
#

Sure:

 Missing GCP service account credentials: requires GCP_SERVICE_ACCOUNT_EMAIL_NIKLAS_API and GCP_SERVICE_ACCOUNT_PRIVATE_KEY_NIKLAS_API environment variables to be set

      24 |       : null;
      25 |   if (!credentials) {
    > 26 |     throw new Error(
         |           ^
      27 |       `Missing GCP service account credentials: requires GCP_SERVICE_ACCOUNT_EMAIL_${context} and GCP_SERVICE_ACCOUNT_PRIVATE_KEY_${context} environment variables to be set`
      28 |     );
      29 |   }
wide nova
#

More context around the error please, the test being ran, stack traces, etc

ocean sparrow
#

Sure

 FAIL  src/api/app/platform/platform.controller.spec.ts
  ● Test suite failed to run

    Missing GCP service account credentials: requires GCP_SERVICE_ACCOUNT_EMAIL_NIKLAS_API and GCP_SERVICE_ACCOUNT_PRIVATE_KEY_NIKLAS_API environment variables to be set

      24 |       : null;
      25 |   if (!credentials) {
    > 26 |     throw new Error(
         |           ^
      27 |       `Missing GCP service account credentials: requires GCP_SERVICE_ACCOUNT_EMAIL_${context} and GCP_SERVICE_ACCOUNT_PRIVATE_KEY_${context} environment variables to be set`
      28 |     );
      29 |   }

      at getGcpServiceAccountCredentials (utils/gcp.ts:26:11)
      at Object.<anonymous> (api/app/bigquery/bigquery.module.ts:14:38)
      at CompileFunctionRuntime._execModule (../node_modules/@side/jest-runtime/src/index.js:101:24)
      at Object.<anonymous> (libs/platform-service/lib/platform-service.module.ts:16:25)
      at CompileFunctionRuntime._execModule (../node_modules/@side/jest-runtime/src/index.js:101:24)
      at Object.<anonymous> (libs/platform-service/index.ts:8:14)
      at CompileFunctionRuntime._execModule (../node_modules/@side/jest-runtime/src/index.js:101:24)
      at Object.<anonymous> (api/app/platform/platform.controller.spec.ts:6:26)
      at CompileFunctionRuntime._execModule (../node_modules/@side/jest-runtime/src/index.js:101:24)
wide nova
#

src/api/app/platform/platform.controller.spec.ts
I would bet that's not the test you think it is

ocean sparrow
#

It's the test that is being ran.

And the function being called:

export function getGcpServiceAccountCredentials(
  context: 'NIKLAS_CLI' | 'NIKLAS_API'
): GcpServiceAccountCredentials {
  const gcpServiceAccountEmail =
    process.env[`GCP_SERVICE_ACCOUNT_EMAIL_${context}`];
  const gcpServiceAccountPrivateKey = process.env[
    `GCP_SERVICE_ACCOUNT_PRIVATE_KEY_${context}`
  ]?.replace(/\\n/g, '\n');
  const credentials =
    gcpServiceAccountEmail !== undefined &&
    gcpServiceAccountPrivateKey !== undefined
      ? {
          client_email: gcpServiceAccountEmail,
          private_key: gcpServiceAccountPrivateKey,
        }
      : null;
  if (!credentials) {
    throw new Error(
      `Missing GCP service account credentials: requires GCP_SERVICE_ACCOUNT_EMAIL_${context} and GCP_SERVICE_ACCOUNT_PRIVATE_KEY_${context} environment variables to be set`
    );
  }
  return credentials;
}

So I guess I should also find a way to mock BigQueryModule, but it's endless if I have to mock all modules of my application 🙈

wide nova
#

That's the thing though, that function shouldn't be called unless the BigQueryModule is imported in the module metadata

#

From what you've shown, this shouldn't be happening, so either something is being left out, or we're looking in the wrong place. Either way, I don't have enough of a view to know what's happening and why

#

If you can provide access to your code, I'll happily take a look

ocean sparrow
#

Thank you for your offer, but this code belonging to my company it won't be possible. But you are confirming one thing: I'm not totally crazy and it should not be happening. I'll debug it again and again and I'll find what's wrong. Thanks a lot for your help 🙏

wide nova
#

Just to confirm, this test

import { Test } from '@nestjs/testing';

import { TrendsService } from './platform-service';
import { createMock } from '@golevelup/ts-jest';

describe('PlatformController', () => {
  beforeEach(async () => {
    const module = await Test.createTestingModule({
      imports: [],
      controllers: [],
      providers: [
        { provide: TrendsService, useValue: createMock<TrendsService>() },
      ],
    }).compile();
  });

  describe('getTrendsAvailablePeriods', () => {
    it('should call computeAvailableTrendPeriods with the correct account slug', async () => {
      expect(true).toBe(true);
    });
  });
});

Is theoretically sound. If you run jest /path/to/platform-service.spec with the above being in that file, does it work?

ocean sparrow
#

Nope it doesn't :

❯ jest src/api/app/platform/platform.controller.spec.ts
 FAIL  src/api/app/platform/platform.controller.spec.ts
  ● Test suite failed to run

    Missing GCP service account credentials: requires GCP_SERVICE_ACCOUNT_EMAIL_NIKLAS_API and GCP_SERVICE_ACCOUNT_PRIVATE_KEY_NIKLAS_API environment variables to be set

      24 |       : null;
      25 |   if (!credentials) {
    > 26 |     throw new Error(
         |           ^
      27 |       `Missing GCP service account credentials: requires GCP_SERVICE_ACCOUNT_EMAIL_${context} and GCP_SERVICE_ACCOUNT_PRIVATE_KEY_${context} environment variables to be set`
      28 |     );
      29 |   }
wide nova
#

Does platform-service imoprt the file with BigQueryModule?

ocean sparrow
#

You mean platform.controller.spec.ts right? I don't have this issue when testing services, only this controller.
The controller has been reduced to the minimum, it works well if constructor is empty, but fails when:
constructor(private readonly materialService: MaterialsService) {}
And this one is being injected BigQueryService

#

So in the end, controller constructor is requiring a service, which is requiring BigGueryService, thus executing BigQueryModule

wide nova
#

No, I mean does the file that contains TrendsService (./platform-service) import the file containing BigQueryModule

ocean sparrow
#

Nope, only BigQueryService

wide nova
#

Does that file import the file with BigQueryModule?

#

I'm trying to help track down why that module's providers metdata is being called

ocean sparrow
#
// bigquery.service.ts
import { Inject, Injectable } from '@nestjs/common';

import { BigQuery } from '@google-cloud/bigquery';

import { GcpServiceAccountCredentials, getGcpLabels } from '../../../utils/gcp';

@Injectable()
export class BigQueryService {
  private readonly bigQueryHandler: BigQuery;

  constructor(
    @Inject('KAYA_DATASET_ID') private readonly datasetId: string,
    @Inject('PROJECT_ID') private readonly projectId: string,
    @Inject('CREDENTIALS')
    private readonly credentials: GcpServiceAccountCredentials,
    @Inject('SCOPES') private readonly scopes: string[]
  ) {
    this.bigQueryHandler = new BigQuery({
      projectId: this.projectId,
      credentials: this.credentials,
      scopes: this.scopes,
    });
  }
  public async query<T>(query: string, params = {}, labels = {}): Promise<T[]> {
    const [job] = await this.bigQueryHandler.createQueryJob({
      location: 'EU',
      defaultDataset: { projectId: this.projectId, datasetId: this.datasetId },
      query,
      params,
      labels: getGcpLabels({ owner: 'platform-service', labels }),
    });

    const [rows] = await job.getQueryResults();

    return rows as T[];
  }
}

No it doesn't import the module, but it's importing a type from a file that contains the function being triggered

#

And the file looks clean:

// gcp.ts
import os from 'os';

import { ENVIRONMENT, NODE_ENV } from '../config';

export interface GcpServiceAccountCredentials {
  client_email: string;
  private_key: string;
}
export function getGcpServiceAccountCredentials(
  context: 'NIKLAS_CLI' | 'NIKLAS_API'
): GcpServiceAccountCredentials {
  const gcpServiceAccountEmail =
    process.env[`GCP_SERVICE_ACCOUNT_EMAIL_${context}`];
  const gcpServiceAccountPrivateKey = process.env[
    `GCP_SERVICE_ACCOUNT_PRIVATE_KEY_${context}`
  ]?.replace(/\\n/g, '\n');
  const credentials =
    gcpServiceAccountEmail !== undefined &&
    gcpServiceAccountPrivateKey !== undefined
      ? {
          client_email: gcpServiceAccountEmail,
          private_key: gcpServiceAccountPrivateKey,
        }
      : null;
  if (!credentials) {
    throw new Error(
      `Missing GCP service account credentials: requires GCP_SERVICE_ACCOUNT_EMAIL_${context} and GCP_SERVICE_ACCOUNT_PRIVATE_KEY_${context} environment variables to be set`
    );
  }
  return credentials;
}

export function getGcpLabels(opts: {
  owner: string;
  labels?: { [key: string]: string };
}): {
  [label: string]: string;
} {
  const hostname = os.hostname();
  const hostnameLabel = hostname.replace(/[^a-zA-Z0-9_]/g, '_').toLowerCase();
  const nodeEnvLabel = NODE_ENV ?? 'undefined';
  return {
    job_owner: opts.owner,
    job_env: ENVIRONMENT,
    job_node_env: nodeEnvLabel,
    job_host: hostnameLabel,
    ...opts.labels,
  };
}
wide nova
#

So, something is calling getGcpServiceAccountCredentials, right?

ocean sparrow
#

Yes, I was assuming it was the module, but maybe no 🤔
I'll comment every other invocation

wide nova
#

That's why I was asking if that module is somehow getting imported

ocean sparrow
#

Nope, only the module is calling this function

import { Module } from '@nestjs/common';

import { getGcpServiceAccountCredentials } from 'src/utils/gcp';
import { createProvider } from 'src/utils/providers';

import { BigQueryService } from './bigquery.service';

@Module({
  providers: [
    createProvider('KAYA_DATASET_ID', process.env.KAYA_DATASET_ID || 'kaya'),
    createProvider('PROJECT_ID', 'xsheet'),
    createProvider(
      'CREDENTIALS',
      getGcpServiceAccountCredentials('NIKLAS_API')
    ),
    createProvider('SCOPES', [
      'https://www.googleapis.com/auth/cloud-platform',
    ]),
    BigQueryService,
  ],
  exports: [BigQueryService],
})
export class BigQueryModule {}
wide nova
#

Where all is this file imported?

#

Because if it's imported in a barrel file, consider that too

ocean sparrow
#

No barrel, only being imported in a module where it's needed

wide nova
#

If this file gets imported at all then the @Module() metadata will be evaluated, and all the providers you have there will end up running. This is because of how decorators work, they're essentially functions at the end of the file that are evaluated at the time of import (like any other top level functions), so what I think is happening is this file is getting imported, the providers are being calculated, even though they aren't used, because they're top-level functions, and you're getting this error

ocean sparrow
#

Thanks a lot @wide nova you are helping me a lot to understand the issue. I'll spend some time on the debugger to understand exactly the trace