machty / ember-concurrency

ember-concurrency is an Ember Addon that enables you to write concise, worry-free, cancelable, restartable, asynchronous tasks.
http://ember-concurrency.com
MIT License
689 stars 157 forks source link

Mocking a task for testing #451

Open cah-brian-gantzler opened 2 years ago

cah-brian-gantzler commented 2 years ago

I have a utility to mock a task for testing I think would be nice for everyone to have.

I would submit it as a test-support helper, but it has a requirement for sinon, not sure if that would be a problem.

It was based on an article, I finished the code. (I can not find the article now, if anyone can please post here, want to give credit where its due)

TODO: The mocked task has no decorators, so if the task you are mocking is using @drop, etc, these will not be on the mockedTask.

Here is the code. Would this be an acceptable test-support helper for this addon.

import sinon from 'sinon';
import { task } from 'ember-concurrency';

class InternalTask {
  _fakeData;
  _fakeMethod;
  promise;
  finishTask;
  rejectTask;

  constructor() {
    this.promise = new Promise((resolve, reject) => {
      this.finishTask = resolve;
      this.rejectTask = reject;
    });
  }

  @task
  *task() {
    if (this._fakeData === undefined && this._fakeMethod === undefined) {
      return yield this.promise;
    }

    return this._fakeData || this._fakeMethod?.(...arguments);
  }
}

/**
 * Allows mocking of a task for testing. Under the covers it uses an internal task, so taskMock.task is the
 * internal task for all the task properties
 *
 * Basic usage for mocking a task
 *
 *     // Creates a mock of the task that returns the given data instead of calling perform
 *     new TaskMock(this.myService, 'getInvoiceReportingCountsTask').returns({});
 *
 *     // Creates a mock that calls a function instead of perform
 *    new TaskMock(this.myService, 'getAllBulletinsForAdminTask').callsFake(() => {
 *      assert.ok(true);
 *      return resolve([]);
 *    });
 *
 *    // Creates a task mock that waits to enable testing things like isRunning.
 *    // call finishTask to end the task and allow the test to continue. Resolved data can be passed.
 *    // call rejectTask to test a failing task
 *    const mockTask = new TaskMock(service, 'submitQueueTask');
 *    assert.ok(service.submitDisabled, "submit is disabled");  // submit is disabled while the task is running
 *    mockTask.finishTask([]);
 *
 *
 *    // The task property of the mock allows access to the task used for testing.
 *    // This is an internal task, not the real task that was mocked
 *    const mockTask = new TaskMock(service, 'submitQueueTask').returns({});
 *    mockTask.task.lastSuccessful
 */
export class TaskMock {
  _internalTask;
  finishTask;
  rejectTask;

  constructor(object, taskName) {
    this._internalTask = new InternalTask();
    if (object) {
      sinon.stub(object, taskName).value(this._internalTask.task);
    }
    this.finishTask = this._internalTask.finishTask;
    this.rejectTask = this._internalTask.rejectTask;
  }

  get task() {
    return this._internalTask.task;
  }

  callsFake(method) {
    this._internalTask._fakeMethod = method;
    return this;
  }

  returns(data) {
    this._internalTask._fakeData = data;
    return this;
  }
}
maxfierke commented 2 years ago

@cah-briangantzler is the goal here to get the derived state without having to call the real task or to control execution of the task in a test context? It seems like it's perhaps getting at both, but I want to make sure I understand the use-case

cah-brian-gantzler commented 2 years ago

Control execution of a task during a test. Should the derived state be actually used in the code, then this mocking for a test would also provide that derived state to allow testing that. The most common state is isRunning, this allows you to test that out in your tests.

If your task is backing rest calls and you are using MirageJS, you would not need to mock the task, as you would just let it call mirage. But if you were arent, this allows you to proved a value without actually calling the task.

So the answer would be both, because you will need to test your apps reactions to both.

cah-brian-gantzler commented 2 years ago

I have updated my task mocking and provided it as an additional addon at https://github.com/bgantzler/ember-concurrency-mock

Given that it is only one file, I would think it would be better served as an actual test helper from this repo.

To your earlier question, the goal is to provide task mocking during a test, but since your actual code may be waiting on isRunning, and you might want to test that as well, it is providing both control and proper state access for a complete mock.

There was an article written a year ago which is what I based mine off of (sorry I cant find it now) and another recent article (https://mfeckie.dev/testing-tasks/) using the same basic principle.

You will find my implementation provides the same basic functionality plus more. The actual stubbing happens via ember-sinon, so there is that requirement. Its only one line though, so I guess I could do the same thing sinon is doing (after some research) and remove the sinon dependency