Hardhat – How to Wait and Listen for External Events in Tests with Ethers

ethers.jseventshardhatmochatesting

I'm waiting for an event to fire in my tests. This isn't happening from a transaction I'm executing so I can't use anything from transaction receipts (for example, these won't work Listening to events using ethers.js on a hardhat test network). And I think this answer is just for truffle.

Here is one of my tests:

it.only("Should successfully make an external API request and get a result", async (done) => {
      apiConsumer.once("DataFullfilled", () => {
        const result = await apiConsumer.volume()
        assert(result > 0)
        done()
      })
      apiConsumer.requestVolumeData()
})

I've tried a number of variations, but I can't seem to figure out what I'm doing wrong. This currently gives me Error: Resolution method is overspecified. Specify a callback *or* return a Promise; not both. And I've read the mocha docs on this error and some other documentation, but I can't seem to figure out what's going on.

Best Answer

With the help of Santiago, I finally got it! So the code should look like this:

it.only("Our event should successfully fire on callback", async () => {
      const callbackValue = 777

      // we setup a promise so we can wait for our callback from the `once` function
      await new Promise(async (resolve, reject) => {
        // setup listener for our event
        apiConsumer.once("DataFullfilled", async () => {
          console.log("DataFullfilled event fired!")
          const volume = await apiConsumer.volume()
          // assert throws an error if it fails, so we need to wrap
          // it in a try/catch so that the promise returns event
          // if it fails.
          try {
            assert.equal(volume.toString(), callbackValue.toString())
            resolve()
          } catch (e) {
            reject(e)
          }
        })
        const transaction = await apiConsumer.requestVolumeData()
        const transactionReceipt = await transaction.wait(1)
        const requestId = transactionReceipt.events[0].topics[1]
        await mockOracle.fulfillOracleRequest(requestId, numToBytes32(callbackValue))
      })
    })

Here is why.

1. How to use the callback in the it

it.only("Our event should successfully fire on callback", async () => {

When you decide to make your callback function async (by having that async ()) you are telling mocha you are going to return a promise that has the results of the test. You can 100% use a callback that looks like (done) but then you can't return a promise. And if you use async() then you can't use done. It has to be one or the other. And this is good, because that would screw stuff up anyways.

Additionally, using done is the old way of doing stuff, so we want to just use a promise when possible.

If we just used () or (done) we will then have to use the .then and .catch syntax... which is sort of meh in my opinion.

2. How to make sure we wait for the callback function in our once function.

In order for everything to wait (including mocha itself) we wrap the whole shebang into a Promise. Why do we do that? Well we can force our test to await for the whole thing to be done. And guess what, we can describe all the stuff we want our test to do inside this promise!

We need to do this, since our success criteria is defined in the callback function of once. Should ethers update so we can do an await contract.event("EVENT_NAME") maybe an issue should be made on the repo... but at the moment, they don't.

Now, when we want the promise to finish successfully we call resolve and if we want it to throw an error for our test, we call reject, inside our promise.

3. How to set it up so that we listen for the event to fire

You'll notice, we call apiConsumer.once before we start calling transactions. This makes sense. We want to add our listener to the event loop. This means, that in the background we have this code just constantly on the job of waaaiiitttting for that event to fire.

So we set it up to listen, then we fire the event later with await mockOracle.fulfillOracleRequest(requestId, numToBytes32(callbackValue)) (which has some code to fire the event) and THEN it will call our async callback function (described as the second parameter to our apiConsumer.once.

4. What's up with that try/catch stuff?

So our callback function has the assert we need for our test, by why is it in a try / catch? Our assert function actually throws an error when it fails, so if it fails inside our callback function, we will never be able to call resolve or reject to let the promise know our test is done. This means you'll run into a really ugly UnhandledPromiseRejectionWarning error (yes, it's an error - even though it says Warning in the name) and you'll never leave the callback, and thus you'll hit a timeout eventually or be stuck there forever.

This is why we wrap it in a try catch. If the assertion passes, yay, we call resolve. If not, ok, we pass the reason for failing e to our catch and call reject(e).

Asserts Always Passing

If you dont wrap your tests like this, you may also run into the issue of your asserts pass no matter what. What's really going on, is that they are probably never actually being hit, so you'll want to make sure that when they are in a callback function to wrap them in a try catch.

Related Topic