OptimalBits / bull

Premium Queue package for handling distributed jobs and messages in NodeJS.
Other
15.43k stars 1.43k forks source link

Add a test mode #962

Open entropitor opened 6 years ago

entropitor commented 6 years ago

It would be nice if bull had a test mode, just like kue: https://github.com/Automattic/kue#testing

This way it's much easier to write unit tests against bull workers

manast commented 6 years ago

That would require more or less to mock all the calls we do to redis, not a simple task, would be the same as having a memory backend instead of Redis.

aleccool213 commented 6 years ago

@entropitor Not really unit testing at that point, more like integration. Its a neat idea though and would be a cool project some people could work on.

tom-james-watson commented 6 years ago

Check out https://github.com/yeahoffline/redis-mock - that gives you your "memory backend" that should be able to be dropped in for testing

tewebsolutions commented 6 years ago

Also recommend: https://github.com/guilleiguaran/fakeredis

apellizzn commented 6 years ago

Was anyone able to setup bull to use https://github.com/yeahoffline/redis-mock ?

tom-james-watson commented 6 years ago

Not me. Also, seeing as bull uses https://github.com/luin/ioredis, technically it would actually be https://github.com/stipsan/ioredis-mock. Unfortunately neither redis-mock, fakeredis nor ioredis-mock are feature-complete. All throw errors for missing methods when used with Bull. In the end I have switched to using a real test redis instance and have written some proper integration tests that way.

apellizzn commented 6 years ago

Leaving this here just in case. I made this working by using redis-mock and adding info and defineCommand to the mocked client

const redis = require('redis-mock')
const client = redis.createClient()
client.info = async () => 'version'
client.defineCommand = () => {}
const queue = new Queue('my queue', { createClient: () => client })
yojeek commented 5 years ago

@apellizzn tried your solution, got error _this.client.client is not a function near __this.client.client('setname', this.clientName()) in worker.js

nbarrera commented 5 years ago

I 've been trying to use ioredis-mock as a testbed to bull

I 've added lua support in a fork to it

But... still I needed to add also some other commands:

Anyway I realized that a command ioredis-mock had already implemented was not behaving as expected, that's the case for: BRPOPLPUSH

The command was not blocking until a new element enters in the right list.

So I 've quit my attempt to make bull work on ioredis-mock... but if someone else wants to pick up the challenge you are welcome đź‘Ť

this is the branch where I 've added the extra commands needed by bull (and also the lua support)

https://github.com/nbarrera/ioredis-mock/tree/use-as-bull-testbed

tom-james-watson commented 5 years ago

Definitely easier to just spin up real redis in a docker container

erickalmeidaptc commented 5 years ago

@apellizzn tried your solution, got error _this.client.client is not a function near __this.client.client('setname', this.clientName()) in worker.js

      // Moking redis connection 
      const redis = require('redis-mock')
      const client = redis.createClient()
      client.info = async () => 'version'
 =>   client.client = async () => ''
      client.defineCommand = () => {}
      const options = {
        createClient: () => client
      };
oddball commented 5 years ago

I got something (and somethings not) working earlier this year and thought I share it, with the hope that someone improves it :)

// __mock__/queues.js" with Jest
import Queue from "bull";
import path from "path";
import { jobs } from "../jobs";
import Redis from "ioredis-mock";

export const redisMockClient = new Redis();

redisMockClient.info = async () => "version";

redisMockClient.client = (clientCommandName, ...args) => {
    if (!redisMockClient.clientProps) {
        redisMockClient.clientProps = {};
    }

    switch (clientCommandName) {
        case "setname": {
            const [name] = args;
            redisMockClient.clientProps.name = name;
            return "OK";
        }
        case "getname":
            return redisMockClient.clientProps.name;
        default:
            throw new Error("This implementation of the client command does not support", clientCommandName);
    }
};

export const jobQueue = new Queue("jobQueue", { createClient: () => redisMockClient });

jobQueue.process(path.join(__dirname, "..", "processor.js"));

const jobNames = Object.keys(jobs);
jobNames.forEach((jobName) => {
    jobQueue.process(jobName, 5, path.join(__dirname, "..", "processor.js"));
});

I was brought up to believe that you should decouple your tests. Spinning up a real redis instance or mongodb or whatever feels wrong. I used mongodb-memory-server for mongodb successfully for tests. I am surprised it was not as easy with Bull and Redis

klouvas commented 5 years ago

Is there any progress with this?

aleksandarn commented 4 years ago

I got something working using ioredis-mock. I am using Jest and here is my mock set-up for my tests:

jest.mock('ioredis', () => {
  const Redis = require('ioredis-mock');
  if (typeof Redis === "object") {
    // the first mock is an ioredis shim because ioredis-mock depends on it
    // https://github.com/stipsan/ioredis-mock/blob/master/src/index.js#L101-L111
    Redis.Command = { _transformer: { argument: {}, reply: {} } };
    return {
      Command: { _transformer: { argument: {}, reply: {} } }
    }
  }
  // second mock for our code
  return function (...args) {
    var instance = new Redis(args);
    instance.options = {};
    instance.info = async () => "version";
    instance.client = (clientCommandName, ...args) => {
      if (!instance.clientProps) {
        instance.clientProps = {};
      }

      switch (clientCommandName) {
        case "setname": {
          const [name] = args;
          instance.clientProps.name = name;
          return "OK";
        }
        case "getname":
          return instance.clientProps.name;
        default:
          throw new Error("This implementation of the client command does not support", clientCommandName);
      }
    };

    return instance;
  }
})

This is based on a code from this thread and a thread about using ioredis-mock with Jest. The issue I am facing is that ioredis-mock has Lua implementation version 5.3, where Redis uses 5.1 and this breaks some scripts. For example it is not possible to add delayed task because it uses bit.band() which is not available on Lua 5.3 as it supports bit-wise operations by internally.

bwkiefer commented 4 years ago

@aleksandarn

I've tried your set up, but have run into an issue where my processor is not picking up any of the jobs that are posted. I can see when I post a job that bull adds keys to the redis mock. Have you got simple job posting and processing working with the mock? Here's a simplified version of my test:

const wait = async function wait(time) {
  return new Promise(resolve => setTimeout(resolve, time));
};

jest.mock('ioredis', () => {
  // ioredis-mock v4.18.2
  const Redis = require('ioredis-mock');
  if (typeof Redis === "object") {
    // the first mock is an ioredis shim because ioredis-mock depends on it
    // https://github.com/stipsan/ioredis-mock/blob/master/src/index.js#L101-L111
    Redis.Command = { _transformer: { argument: {}, reply: {} } };
    return {
      Command: { _transformer: { argument: {}, reply: {} } }
    }
  }
  // second mock for our code
  return function (...args) {
    var instance = new Redis(args);
    instance.options = {};
    instance.info = async () => "version";
    instance.client = (clientCommandName, ...args) => {
      if (!instance.clientProps) {
        instance.clientProps = {};
      }

      switch (clientCommandName) {
        case "setname": {
          const [name] = args;
          instance.clientProps.name = name;
          return "OK";
        }
        case "getname":
          return instance.clientProps.name;
        default:
          throw new Error("This implementation of the client command does not support", clientCommandName);
      }
    };

    return instance;
  }
});

// bull v3.12.0
const Queue = require('bull');
// ioredis v4.14.0
const Redis = require('ioredis');

describe('test', () => {
  it('test', async () => {
    expect.assertions(2);

    let redis = new Redis();
    const queue = new Queue('test', {
      createClient: () => {
        return redis;
      }
    });

    const theJob = { 'status': true };

    queue.process(async (job, done) => {
      // does not get this far
      console.log('processing: ', job.data);
      expect(job.data).toMatchObject(theJob);

      done(null, job.data);
    });

    await queue.add(theJob);

    let keyRes = await redis.keys('*');
    // Prints [ 'bull:test:id', 'bull:test:1.0', 'bull:test:wait' ]
    console.log(keyRes);

    await wait(100);

    expect(1).toBe(1);
  });
});
omairvaiyani commented 4 years ago

For anyone that stumbles upon this issue - neither Bull 3, nor the upcoming Bull 4 currently natively support a test/in-memory environment for local development, and no mock clients (ioredis-mock, redis-mock) work with Bull. I've managed to find a working solution using [redis-server]()

const startLocalRedisServer = async () => {
  const MOCK_SERVER_PORT = 6379;
  try {
    const RedisServer = require('redis-server');
    const server = new RedisServer();
    await server.open({ port: MOCK_SERVER_PORT });
  } catch (e) {
    console.error('unable to start local redis-server', e);
    process.exit(1);
  }
  return MOCK_SERVER_PORT;
};
// Bull setup 
const setupQueue = async () => {
  const port =  await  startLocalRedisServer();
  return new Queue('General', { redis: { port } });
}
aleksandarn commented 4 years ago

Probably this issue in ioredis-mock (apart from the Lua version) has something to do with the fact that bull is not working properly with it: https://github.com/stipsan/ioredis-mock/issues/773

prithvin commented 4 years ago

This approach works perfectly for me:

In my src/__mocks__/ioredis.js, i included

/* @flow */
const Redis = require('ioredis-mock');

class RedisMock {
  static Command = { _transformer: { argument: {}, reply: {} } };
  static _transformer = { argument: {}, reply: {} };

  constructor(...args: Object) {
    Object.assign(RedisMock, Redis);
    const instance = new Redis(args);
    instance.options = {};
    // semver in redis client connection requires minimum version 5.0.0
    // https://github.com/taskforcesh/bullmq/blob/da8cdb42827c22fea12b9c2f4c0c80fbad786b98/src/classes/redis-connection.ts#L9
    instance.info = async () => 'redis_version:5.0.0';
    instance.client = (clientCommandName, ...addArgs) => {
      if (!instance.clientProps) {
        instance.clientProps = {};
      }

      switch (clientCommandName) {
        case 'setname': {
          const [name] = addArgs;
          instance.clientProps.name = name;
          return 'OK';
        }
        case 'getname':
          return instance.clientProps.name;
        default:
          throw new Error(`This implementation of the client command does not support ${clientCommandName}`);
      }
    };
    return instance;
  }
}

module.exports = RedisMock;

and in my bullmq instance setup file, i included

/* @flow */

const CONSTS = require('../config/config');
const { Queue } = require('bullmq');
const IORedis = require('ioredis');
const logger = require('../utils/logger');

const connection = new IORedis(CONSTS.BULL_MQ_REDIS); // obj with host, port
connection.on('connect', () => {
  logger.info('Connected to redis');
});
connection.on('error', (err) => {
  logger.info('Error connceting to bullmq', err);
});

const bullMQQueue = new Queue(
  'bullmqqueue',
  {
    connection,
  },
);

module.exports = bullMQQueue;
phrozen commented 4 years ago

This approach works perfectly for me:

I tried this and Bull still tries to connect to the default Redis instance in my tests. I'm using proxyquire and mocking the redis connection, but somehow BullMQ still tries to create a connection of it's own with IORedis.

Edit: OK, I made it work by adding the next lines:

instance.connect = () => {};
instance.disconnect = () => {};
instance.duplicate = () => instance;

Apparently this is what BullMQ 4.0 checks to see if it can reuse the connection:

https://github.com/taskforcesh/bullmq/blob/36726bfb01430af8ec606f36423fc187e4a06fb4/src/utils.ts#L41

prodoxx commented 4 years ago

I don't know if it's useful but Shoryuken which is used for SQS queues in Ruby has something called an inline adapter used to run jobs inline. So, instead of the task getting added to a queue, it just runs the perform function. Maybe this is a good idea to do with bull? Here is a reference: https://github.com/phstc/shoryuken/wiki/Shoryuken-Inline-adapter

eithermonad commented 4 years ago

@tom-james-watson Hello. You mentioned that you use a real Redis instance for your integration tests. I'm looking to do the same, and I was hoping I could ask you a question about your implementation.

Suppose you just have a simple POST endpoint that enqueues a job with Bull. Suppose also that you use something like Supertest to make a POST Request to that endpoint. When the POST Request returns 201 or 202, you can't immediately start asserting that side-effects of the processed jobs have occurred correctly because there could be a small delay between the POST response and the actual execution of the job. I was wondering how you handle that with your setup.

Thanks.

tom-james-watson commented 4 years ago

The short answer is to listen to the queue's completed (or whatever else) event in your test. E.g. queue.on('completed', (job, result) => {...

eithermonad commented 4 years ago

@tom-james-watson Thanks very much, so I take it, with Jest for example, you'd use something like expect.assertions(x) or even the done callback to keep the test running until the job is executed?

In the event that you have deffered jobs (say 15 minutes in the future), would you just try to get away with asserting that the job was added with the correct delay and not worry about its execution?

Thanks again.

tom-james-watson commented 4 years ago

Let's not derail this thread - every time you comment you're sending notifications to everyone who has participated in this thread. But yes, most of my tests simply check check that jobs are added with the expected parameters. For these I mock the add function so that it doesn't actually try and execute the job. Then I have separate tests specifically for the internals of the jobs where nothing is mocked and I let everything run for real on redis and wait for the job to complete.

eithermonad commented 4 years ago

Thanks. I agree we're sightly off topic from the original objective of the thread, but I figured the information was somewhat relevant for those performing integration tests with Bull.

Anyway, I'll leave it at that and thanks for your time.

jaschaio commented 4 years ago

Trying to use @prithvin suggested solition using ioredis-mock but with bull:v3. If I add a job with options like queue.add( 'name', {}, { delay: 900000 } ) I get a weird error:

Error trying to loading or executing lua code string in VM: [string "--[[..."]:60: attempt to index a nil value (global 'bit')
    at Object.luaExecString (/node_modules/ioredis-mock/lib/lua.js:29:13)
    at RedisMock.customCommand2 (/node_modules/ioredis-mock/lib/commands/defineCommand.js:124:8)
    at safelyExecuteCommand (/node_modules/ioredis-mock/lib/command.js:114:32)
    at /node_modules/ioredis-mock/lib/command.js:139:43
    at new Promise (<anonymous>)
    at RedisMock.addJob (/node_modules/ioredis-mock/lib/command.js:138:45)
    at Object.addJob (/node_modules/bull/lib/scripts.js:41:19)
    at addJob (/node_modules/bull/lib/job.js:66:18)
    at /node_modules/bull/lib/job.js:79:14
stigvanbrabant commented 4 years ago

Someone that got this to work with bull v3? For some reason when i try @prithvin solution my test suite gets stuck (without any errors getting thrown).

jaschaio commented 4 years ago

@stigvanbrabant I didn’t and ended up switching to bullmq and just starting a Docker container with redis before running the test suite.

peterp commented 3 years ago

I'm not doing anything sophisticated with bull, so I created this mock that executes the job the moment a job is added, maybe someone can expand on this.

// __mocks__/bull.ts
export default class Queue {
  name: string
  constructor(name) {
    this.name = name
  }

  process = (fn) => {
    console.log(`Registered function ${this.name}`)
    this.processFn = fn
  }

  add = (data) => {
    console.log(`Running ${this.name}`)
    return this.processFn({ data })
  }
}
thisismydesign commented 3 years ago

My use case is not to execute jobs scheduled during testing. I was hoping to solve this by instantiating Bull using different default job options, such as attempts: 0. But that doesn't seem to work. I'm currently using an arbitrary high delay.

How about supporting attempts: 0 on JobOpts? Or whatever else flag that simply skips processing.

I'm using NestJS and also posted a question on SO: https://stackoverflow.com/questions/67489690/nestjs-bull-queues-how-to-skip-processing-in-test-env/67489691

boredland commented 2 years ago

Edit: OK, I made it work by adding the next lines:

instance.connect = () => {};
instance.disconnect = () => {};
instance.duplicate = () => instance;

Apparently this is what BullMQ 4.0 checks to see if it can reuse the connection:

https://github.com/taskforcesh/bullmq/blob/36726bfb01430af8ec606f36423fc187e4a06fb4/src/utils.ts#L41

DISCLAIMER: this applies to bullmq (which you should use), not bulljs (v3).

So we would just need to mock that one, I think. This currently works for me just fine:

import * as bullUtils from 'bullmq/dist/utils';

jest.mock('ioredis', () => require('ioredis-mock/jest'));
const bullMock = jest.spyOn(bullUtils, 'isRedisInstance');

bullMock.mockImplementation((_obj: unknown) => {
  return true;
});

If ioredis-mock is missing ioredis-features that we need to test bullmq properly, we should raise that upstream with them, thats imo nothing bullmq should solve.

jcarlsonautomatiq commented 10 months ago

prithvin solution almost works though there were some extra pain points if you try to pause the queues in a unit test. This is for Bull 4.0 which does some extra disconnect / connect things.

import Redis from 'ioredis-mock';

export default class RedisMock {
      static Command = { _transformer: { argument: {}, reply: {} } };
      static _transformer = { argument: {}, reply: {} };

      constructor(...args) {
        Object.assign(RedisMock, Redis);

        // Sketchy but bull is using elements that are NOT in ioredis-mock but ARE on Redis itself.
        let instance: any = new Redis(args);

        instance.options = {};
        // semver in redis client connection requires minimum version 5.0.0
        // https://github.com/taskforcesh/bullmq/blob/da8cdb42827c22fea12b9c2f4c0c80fbad786b98/src/classes/redis-connection.ts#L9
        instance.info = async () => 'redis_version:5.0.0';
        instance.client = (clientCommandName, ...addArgs) => {
          if (!instance.clientProps) {
            instance.clientProps = {};
          }
          switch (clientCommandName) {
            case 'setname': {
              const [name] = addArgs;
              instance.clientProps.name = name;
              return 'OK';
            }
            case 'getname':
              return instance.clientProps.name;
            default:
              throw new Error(`This implementation of the client command does not support ${clientCommandName}`);
          }
        }

        // ioredis-mock does not emit an 'end' when it calls quit, this causes issues for bull
        // which will not resolve promises that set the queue to paused.
        let reallyDie = instance.quit.bind(instance);
        async function pleaseDie() {
          setTimeout(() => {
            instance.emit('end');
          }, 100);
          try {
            let quit = await reallyDie();
            return quit;
          } catch(error) {
            console.error("Failed to actually quit", error);
          }
        }
        instance.quit = pleaseDie;

        let reallyDC = instance.disconnect.bind(instance);
        instance.disconnect = function() {
          // ioredis-mock does not set connected to false on a disconnect which also freaks bull out.
          instance.connected = false;
          instance.status = "disconnected";  // You do not want reconnecting etc
          return reallyDC();
        };
        return instance;
      }
    }

Then before your jest tests

jest.mock('ioredis', () => {
  return RedisMock;
});
null-prophet commented 9 months ago

prithvin solution almost works though there were some extra pain points if you try to pause the queues in a unit test. This is for Bull 4.0 which does some extra disconnect / connect things.

import Redis from 'ioredis-mock';

export default class RedisMock {
      static Command = { _transformer: { argument: {}, reply: {} } };
      static _transformer = { argument: {}, reply: {} };

      constructor(...args) {
        Object.assign(RedisMock, Redis);

        // Sketchy but bull is using elements that are NOT in ioredis-mock but ARE on Redis itself.
        let instance: any = new Redis(args);

        instance.options = {};
        // semver in redis client connection requires minimum version 5.0.0
        // https://github.com/taskforcesh/bullmq/blob/da8cdb42827c22fea12b9c2f4c0c80fbad786b98/src/classes/redis-connection.ts#L9
        instance.info = async () => 'redis_version:5.0.0';
        instance.client = (clientCommandName, ...addArgs) => {
          if (!instance.clientProps) {
            instance.clientProps = {};
          }
          switch (clientCommandName) {
            case 'setname': {
              const [name] = addArgs;
              instance.clientProps.name = name;
              return 'OK';
            }
            case 'getname':
              return instance.clientProps.name;
            default:
              throw new Error(`This implementation of the client command does not support ${clientCommandName}`);
          }
        }

        // ioredis-mock does not emit an 'end' when it calls quit, this causes issues for bull
        // which will not resolve promises that set the queue to paused.
        let reallyDie = instance.quit.bind(instance);
        async function pleaseDie() {
          setTimeout(() => {
            instance.emit('end');
          }, 100);
          try {
            let quit = await reallyDie();
            return quit;
          } catch(error) {
            console.error("Failed to actually quit", error);
          }
        }
        instance.quit = pleaseDie;

        let reallyDC = instance.disconnect.bind(instance);
        instance.disconnect = function() {
          // ioredis-mock does not set connected to false on a disconnect which also freaks bull out.
          instance.connected = false;
          instance.status = "disconnected";  // You do not want reconnecting etc
          return reallyDC();
        };
        return instance;
      }
    }

Then before your jest tests

jest.mock('ioredis', () => {
  return RedisMock;
});

Thankyou so much for this, do you have any examples how your testing your workers queues?

Just all new to me and could use some pointers on meaningful unit tests.