Home > Software engineering >  Jest Mock HttpClient
Jest Mock HttpClient

Time:11-04

NodeJS, In integration test currently I have httpClient which calls real service. Now trying to change it to mock API, but having "Cannot read property 'mockResolvedValue' of undefined"

Nutrition.test.js

const HttpClient = require('../../../../src/utils/httpClient');
const { NutritionService } = require('../../../../src/services');
const { LoggerWithContext } = require('../../../../src/utils/logger');


jest.mock('../../../../src/utils/httpClient');
describe('nutrition integration test', () => {
  // skipping for now because the product nutrition is no longer found. We need to have a product that will always be available in the test.
  // eslint-disable-next-line jest/no-disabled-tests
  it('new basic  CABL test', async () => {
    const ctx = {
      state: {
        user: {
          username: 'user',
          opco: '067',
        },
        applicationInfo: {
          correlationId: '2',
        },
      },
      request: {
        headers: {
          requestId: '1',
        },
      },
    };
    
    const logger = new LoggerWithContext(ctx.state.user, ctx.request.header);
    const httpClient = new HttpClient(ctx, logger);

    const  mockedResponse = {data: {userName: "test-user", address: "India"}}
    httpClient.get.mockResolvedValue(mockedResponse);
    const nutritionService = new NutritionService(ctx, httpClient, logger);

    
    const nutritionInfo = await nutritionService.getNutritionInfo({
      sellerId: 'CABL',
      productId: '0036681',
    });
    expect(typeof nutritionInfo.ingredientStatement).toBe('string');
  });

nutritionService.js - this is a service which is making call to real service during test in method "requestNutritionData" which looks like need to be mocked

const get = require('lodash/get');
const config = require('../../../config');
const responseConverter = require('../../utils/responseConverter');
const DataSourceError = require('../../utils/exceptions/dataSourceError');
const { getErrorInfo } = require('../../utils/error');

const acceptLanguage = {
  key: 'Accept-Language',
  value: 'accept-language',
};

class NutritionService {
  constructor(context, httpClient, logger) {
    this.userName = context.state.user.username;
    this.httpClient = httpClient;
    this.logger = logger;
    this.SPAN_NAME = 'nutrition';
    this.locale = context.request.headers[acceptLanguage.value];
    this.headers = {
      [acceptLanguage.key]: this.locale,
      Authorization: config.credentials.nutritionKey,
    };
  }

  async getNutritionInfo({ sellerId, productId }) {
    try {
      const response = await this.requestNutritionData(productId, sellerId);
      if (response.data.data) {
        response.data.data = responseConverter(response.data.data, this.locale);
        return this.transformResponse(response.data.data);
      }
      return null;
    } catch (error) {
      this.logger.error(
        `Error fetching product nutrition data for [sellerId=${sellerId}, productId=${productId}]. Error: ${error.message}`,
        { error },
      );
      throw new DataSourceError(
        getErrorInfo({
          errorMessage: `${error.message} for [sellerId=${sellerId}, productId=${productId}]`,
          statusCode: get(error.response, 'status', error.code),
          dataSource: 'Platform Product Service - Product Nutrition API',
        }),
      );
    }
  }

  async requestNutritionData(productId, sellerId) {
    // sample data
    // productId = '3507035';
    // sellerId = 'seller-0';
    const url = `${config.services.productInfo.url}sellers/${sellerId}/products/${productId}/nutrition`;
    return this.httpClient.get(url, { headers: this.headers, spanName: this.SPAN_NAME, isTokenRequired: false });
  }

error - this is the error which I have when I run it

Cannot read property 'mockResolvedValue' of undefined
TypeError: Cannot read property 'mockResolvedValue' of undefined
    at Object.<anonymous> (D:\Users\ksaz6883\IdeaProjects\cx-product-graph\tests\integration\v1\nutrition\nutrition.test.js:35:20)

httpClient - this client was written manually, not like used library.

const { get } = require('lodash');
const axios = require('axios');
const hoek = require('@hapi/hoek');
const getToken = require('@syscolabs/cx-token-service-js');
const tracer = require('dd-trace');
const { SYY_AUTHORIZATION } = require('./constants');

const traceGetToken = async () => tracer.trace('getToken', async () => getToken());

/**
 * This module is used to send http requests. It executes http calls against api central with an api central auth token, only when required.
 *
 */
class HttpClient {
  constructor(ctx, logger) {
    this.ctx = ctx;
    this.logger = logger;

    /**
     * This method is used to send http requests, when the http method is either POST, PUT or PATCH
     *
     * @param {String} url The endpoint of the request
     * @param data The request body
     * @param options The options are spanName, timeout, isTokenRequired
     * @param {String} spanName The name to be used for the span
     * @param {number} timeout The timeout in milliseconds. Default value is 5000.
     * @param {boolean} isTokenRequired The flag to identify whether api central auth token should be retrieved or not. Default value is true.
     *
     * @returns response Http response for the request
     * @throws error This will be thrown when response could not be retrieved
     */
    ['post', 'put', 'patch'].forEach((method) => {
      this[method] = async (url, data, options) => {
        const validatedOptions = this.getValidatedOptions(options);
        const validatedHeaders = this.getValidatedHeaders(options);
        const configObj = {
          url,
          method,
          data,
          headers: validatedHeaders,
          timeout: validatedOptions.timeout,
        };

        try {
          const axiosConfig = await this.constructConfig(configObj, validatedOptions);
          const getResponse = async () => axios[method](url, data, axiosConfig);
          const response = await tracer.trace(options && options.spanName, getResponse);
          this.logger.info(`Request successful | response: ${JSON.stringify(response.data)}`);
          return response;
        } catch (error) {
          this.logger.error(`Request failed. Error: ${error.message}`, { error });
          throw error;
        }
      };
    });

    /**
     * This method is used to send http requests, when the http method is either GET or DELETE
     *
     * @param {String} url The endpoint of the request
     * @param options The options are spanName, timeout, isTokenRequired
     * @param {String} spanName The name to be used for the span
     * @param {number} timeout The timeout in milliseconds. Default value is 5000.
     * @param {boolean} isTokenRequired The flag to identify whether api central auth token should be retrieved or not. Default value is true.
     *
     * @returns response Http response for the request
     * @throws error This will be thrown when response could not be retrieved
     */
    ['get', 'delete'].forEach((method) => {
      this[method] = async (url, options) => {
        const validatedOptions = this.getValidatedOptions(options);
        const validatedHeaders = this.getValidatedHeaders(options);
        const configObj = {
          url,
          method,
          headers: validatedHeaders,
          timeout: validatedOptions.timeout,
        };

        try {
          let axiosConfig = { timeout: validatedOptions.timeout };
          if (options && options.spanName) {
            axiosConfig = await this.constructConfig(configObj, validatedOptions);
          }
          const getResponse = async () => axios[method](url, axiosConfig);
          const response = await tracer.trace(options && options.spanName, getResponse);
          this.logger.info(`Request successful | response: ${JSON.stringify(response.data)}`);
          return response;
        } catch (error) {
          this.logger.error(`Request failed. Error: ${error.message}`, { error });
          throw error;
        }
      };
    });
  }

  /**
   * This method will construct final axios configs, by modifying the header parameters.
   *
   * @param configObj The config object required for calling axios method
   * @param options The options object
   * @returns configObj The config object with modified header parameters
   */
  async constructConfig(configObj, options) {
    let token;
    if (options.isTokenRequired) {
      token = await traceGetToken();
    }

    const authHeader = (token && { Authorization: `Bearer ${token}` }) || {};
    const defaults = {
      headers: {
        ...authHeader,
        'SYY-Correlation-Id': this.ctx.state.applicationInfo.correlationId,
        'SYY-Request-Id': this.ctx.request.headers.requestId,
        Accept: 'application/json',
        'Content-Type': 'application/json',
      },
    };

    if (options.isCXService) {
      defaults.headers[SYY_AUTHORIZATION] = get(this.ctx, ['request', 'headers', SYY_AUTHORIZATION], null);
    }

    return hoek.applyToDefaults(defaults, configObj);
  }

  getValidatedOptions(options) {
    const timeout = get(options, 'timeout', process.env.DEFAULT_TIMEOUT);
    const isTokenRequired = get(options, 'isTokenRequired', true);
    const isCXService = get(options, 'isCXService', false);

    return {
      timeout,
      isTokenRequired,
      isCXService,
    };
  }

  getValidatedHeaders(options) {
    return (options && options.headers) || {};
  }
}

module.exports = HttpClient;

CodePudding user response:

I think you're using the whole class when you try to mock the response, intead of using the instance you created there.

it should be:

httpClient.get.mockResolvedValue(mockedResponse);

instead of

HttpClient.get.mockResolvedValue(mockedResponse);

const HttpClient = require('../../../../src/utils/httpClient');
const { NutritionService } = require('../../../../src/services');
const { LoggerWithContext } = require('../../../../src/utils/logger');


jest.mock('../../../../src/utils/httpClient');
describe('nutrition integration test', () => {
  // skipping for now because the product nutrition is no longer found. We need to have a product that will always be available in the test.
  // eslint-disable-next-line jest/no-disabled-tests
  it('new basic  CABL test', async () => {
    const ctx = {
      state: {
        user: {
          username: 'user',
          opco: '067',
        },
        applicationInfo: {
          correlationId: '2',
        },
      },
      request: {
        headers: {
          requestId: '1',
        },
      },
    };
    
    const logger = new LoggerWithContext(ctx.state.user, ctx.request.header);
    const httpClient = new HttpClient(ctx, logger);

    const  mockedResponse = {data: {userName: "test-user", address: "India"}}
    // Shouldn't this be using the instance instead of the class?
    // I modified HttpClient to httpClient
    httpClient.get.mockResolvedValue(mockedResponse);
    const nutritionService = new NutritionService(ctx, httpClient, logger);

    
    const nutritionInfo = await nutritionService.getNutritionInfo({
      sellerId: 'CABL',
      productId: '0036681',
    });
    expect(typeof nutritionInfo.ingredientStatement).toBe('string');
  });

UPDATE:

I just realized you're trying to call "mockResolveValue" from a common object and not an spy. What about doing?

jest.spyOn(httpClient, 'get').mockResolvedValue(mockedResponse);
  • Related