Unit test if event handler called promises one after the other (synchronously)

Viewed 739

I have a Vue button click handler that based on the arguments it takes, can:

  • call just request A
  • call just request B
  • call request A and B - but it should call them one after the other (if request A returns successfully, then call request B. Basically, the implementation can't use Promise.all()).

My problem is that I don't know how to unit test the "call A and B one after the other" behavior with Jest.

Implementation

Here's the event handler, it runs after clicking a button:

const loadA = this.$store.dispatch['loadA']; //note these are FUNCTIONS THAT RETURNS A PROMISE
const loadB = this.$store.dispatch['loadB'];
async makeRequests(shouldMakeRequestA, shouldMakeRequestB) {
  const requests = [];
  if(shouldMakeRequestA) requests.push(loadA);
  if(shouldMakeRequestB) requests.push(loadB);

  for(const request in requests) {
    await request(); //waits for request to return before calling for second one
  }
}

Testing

The correct test case should:

  • FAIL❌ when:

    • the implementation calls two requests at the same time eg.:

      • () => { Promise.all([loadA(), loadB()]) }
      • () => { loadA(); loadB() }
  • PASS✔️ when:

    • the implementation calls loadA, waits for it's promise to resolve, then calls loadB, eg:
      • () => {await loadA(); await loadB();}

Here's my take on the test case I described, but it seems very fragile to race conditions and would be difficult to understand to colleagues I assume.

//component.spec.js
import MyCustomButton from '@/components/MyCustomButton.vue'
import TheComponentWeAreTesting from '@/components/TheComponentWeAreTesting'
describe('foo', () => {
const resolveAfterOneSecond = () => new Promise(r => setTimeout(r, 1000));

let wrapper;
const loadA = jest.fn(resolveAfterOneSecond);
const loadB = jest.fn(resolveAfterOneSecond);

beforeEach(() => {
  wrapper = shallowMount(TheComponentWeAreTesting, store: new Vuex.Store({actions: {loadA, loadB}});
})

it('runs A and B one after the other', async () => {
  wrapper.find(MyCustomButton).vm.$emit('click');
  /*
    One of the major problems with my approach is that
    I don't know how much time has passed after I await $nextTick.
    Both requests resolve after 2000 ms total (as mocked above with setTimeout)
    But how much time has passed after $nextTick is resolved?
    700ms? 1300? 1999ms?
  */
  await wrapper.vm.$nextTick();
  /*
   Because I don't know how much time did it take for $nextTick to resolve
   I need to wait a few extra ms so the test passes at all
   Basically, you have to take my word for it that "500ms" is the value that makes the test pass
  */
  awat new Promise(r => setTimeout(r, 500));
  const callCount = loadA.mock.calls.length + loadB.mock.calls.length;
  expect(callCount).toBe(1); //expect first request to have been sent out, but the second one shouldn't be sent out yet at this point
}
}

Is there a better way to test this behavior? I know of eg jest.advanceTimersByTime, but that advances ALL timers, not the current one.

1 Answers

I would replace await new Promise(r => setTimeout(r, 500)); with some handler as

async makeRequests(shouldMakeRequestA, shouldMakeRequestB) {
  const requests = [];
  if(shouldMakeRequestA) requests.push(loadA);
  if(shouldMakeRequestB) requests.push(loadB);

  for(const request in requests) {
    await request(); //waits for request to return before calling for second one
  }
}

returns promise.

this.handler = (async() => {
        const requests = [];
        if (shouldMakeRequestA) requests.push(loadA);
        if (shouldMakeRequestB) requests.push(loadB);

        for (const request of requests) {
          await request();
        }
      })()

**Example snippet **

Vue.config.devtools = false;
Vue.config.productionTip = false;


const {
  shallowMount
} = VueTestUtils;

const {
  core: {
    beforeEach,
    describe,
    it,
    expect,
    run,
    jest
  },
} = window.jestLite;

const resolveAfterOneSecond = () => new Promise(r => setTimeout(r, 1000));
let loadA = resolveAfterOneSecond;
let loadB = resolveAfterOneSecond;

const combineAndSendRequests = async function*(shouldMakeRequestA, shouldMakeRequestB) {

  if (shouldMakeRequestA) {
    await loadA();
    yield 1;
  }

  if (shouldMakeRequestB) {
    await loadB();
    yield 2;
  }
}

const TestComponent = Vue.component('test-component', {

  template: `<button @click="sendRequests()">Send</button>`,
  data() {
    return {
      handler: null
    }
  },
  methods: {
    sendRequests() {
      const shouldMakeRequestA = true;
      const shouldMakeRequestB = true;

      this.handler = (async() => {
        for await (let promise of combineAndSendRequests(shouldMakeRequestA, shouldMakeRequestB)) {

        }

      })();
    }
  }
})

var app = new Vue({
  el: '#app'
})

document.querySelector("#tests").addEventListener("click", (event) => {
  const element = event.target;
  element.dataset.running = true;
  element.textContent = "Running..."

  loadA = jest.fn(resolveAfterOneSecond);
  loadB = jest.fn(resolveAfterOneSecond);

  describe("combineAndSendRequests", () => {

    it('runs A and B one after the other', async() => {

      const shouldMakeRequestA = true;
      const shouldMakeRequestB = true;
      const iterator = combineAndSendRequests(shouldMakeRequestA, shouldMakeRequestB);

      await iterator.next();
      let loadACallsCount = loadA.mock.calls.length;
      let loadBCallsCount = loadB.mock.calls.length;
      expect(loadACallsCount).toBe(1);
      expect(loadBCallsCount).toBe(0);

      await iterator.next();
      loadBCallsCount = loadB.mock.calls.length;
      expect(loadBCallsCount).toBe(1);

      const callsCount = loadA.mock.calls.length + loadB.mock.calls.length;
      expect(callsCount).toBe(2);
    });

  });

  describe("test-component", () => {
    let wrapper = null;

    beforeEach(() => {
      wrapper = shallowMount(TestComponent);

    })

    it('runs request after click', async() => {
      wrapper.find("button").trigger('click');

      await wrapper.vm.$nextTick();
      const handler = wrapper.vm.$data.handler;
      expect(handler).not.toBe(null);

    });
  });

  run().then(result => {

    console.log(result);
    delete element.dataset.running;

    if (!result.some(pr => pr.status.includes("fail"))) {
      element.textContent = "Passed!"
      element.dataset.pass = true;
    } else {
      element.textContent = "Fail!"
      element.dataset.fail = true;
    }
  })


})
#tests {
  margin-top: 1rem;
  padding: 0.5rem;
  border: 1px solid black;
  cursor: pointer;
}

button[data-pass] {
  background: green;
  color: white;
}

button[data-running] {
  background: orange;
  color: white;
}

button[data-fail] {
  background: red;
  color: white;
}
<script src="https://unpkg.com/jest-lite@1.0.0-alpha.4/dist/core.js"></script>
<script src="https://unpkg.com/vue@2.6.11/dist/vue.js"></script>
<script src="https://www.unpkg.com/vue-template-compiler@2.6.11/browser.js"></script>
<script src="https://unpkg.com/@vue/test-utils@1.0.3/dist/vue-test-utils.umd.js"></script>


<div id="app">
  <test-component></test-component>
</div>

<button id="tests">Run Tests</button>

Related