Unit Testing WASM Functions

Previous Top Next
WebAssembly Implementation Unit Testing WebAssembly Functions JavaScript Host Environment

Creating a Unit-Test Framework

Given the current state of WebAssembly Text development tools (which is pretty minimal to be honest), the approach adopted here for unit testing individual WASM functions is somewhat cumbersome and less than ideal; however, it does provide a basic framework.

It would not be particularly good practice to take a private WASM function and temporarily give it an export definition, simply for the purpose of testing. Instead, for each private function that needs to be unit tested, a corresponding public test function has been created that:

  1. Calls the private WASM function passing in a known test value and trapping the return value
  2. Calls a unit-test function imported from the host environment passing in:
    • An arbitrary test id for identifying which function is being tested
    • The function’s actual result
    • The expected result
  3. The unit-test function in the host environment then determines the success or failure of the WASM unit test much like an assert function

IMPORTANT
Once unit testing is complete, all the unit test WASM functions should be commented out as they serve no useful purpose in the production version of the binary.

This is certainly not an ideal way of performing test-driven development; however, the reality is that writing code directly in WebAssembly Text is not really the mainstream use case. Since almost all .wasm files are generated by compilers from source code written in a high-level language such as Rust (where there is already a powerful testing framework), it may be that those involved in the development of WebAssembly itself consider this something of a non-goal…

Nonetheless, if you want to develop code directly in WebAssembly Text, the following unit testing approach (or something very like it) will be needed:

Manual Workaround: An Example

In this example, we will create a unit test for a private WASM function used by the SHA256 algorithm: $choice.1

;; $choice = (e AND f) XOR (NOT(e) AND g)
;; Since WebAssembly has no bitwise NOT instruction, NOT must be implemented as i32.xor($val, -1)
(func $choice
      (param $e i32)
      (param $f i32)
      (param $g i32)
      (result i32)
  (i32.xor
    (i32.and (local.get $e) (local.get $f))
    (i32.and (i32.xor (local.get $e) (i32.const -1)) (local.get $g))
  )
)

Instead of temporarily making $choice public and testing it directly, we will create a specific public, unit-test wrapper function:

(func (export "should_return_choice")
  (call $check_result         ;; Unit test function imported from the host environment
    (i32.const 1)             ;; Test id
    (call $choice             ;; Call to private function returns some result
      (i32.const 0x510E527F)  ;; $e
      (i32.const 0x9B05688C)  ;; $f
      (i32.const 0x1F83D9AB)  ;; $g
    )
    (i32.const 0x1F85C98C)    ;; Expected result
  )
)

Function $check_result is a unit test function imported from the host environment. It’s purpose is simply to compare whether, for a given private WASM function (identified by some arbitrary test id), the returned value matches the expected value.

(module
  (import "test" "checkResult"
    (func $check_result
          (param i32)  ;; Arg 0 - Arbitrary test id
          (param i32)  ;; Arg 1 - Got value
          (param i32)  ;; Arg 2 - Expected value
    )
  )

  ;; SNIP
)

Based on the value of the test id, the host environment function can then identify which private WASM function is being tested and check the actual and expected results appropriately.

// Check the result returned by a WASM function test
const wasmTestCheckResult = (testId, gotI32, expectedI32) => {
  let fnName = ""

  switch (true) {
    case testId == 1:
      fnName = "$choice"

      if (gotI32 !== expectedI32) {
         console.log(`Success: ${fnName}`)
      } else {
         console.error(`Error: ${fnName} Got ${gotI32}, expected ${expectedI32}`)
      }

      break
  }
}

When the host environment instantiates the WASM module, it must supply an environment object that contains at least a reference to the above function.

const hostEnv = {
  // SNIP - other stuff
  "test": {
    "checkResult": wasmTestCheckResult,
  }
  // SNIP - more stuff
}

Finally, create a unit test module in the host environment for running all the WASM unit tests

// Async function to instantiate WASM module/instance
const startWasm =
  async pathToWasmFile => {
    let wasmMod = await WebAssembly.instantiate(
      new Uint8Array(fs.readFileSync(pathToWasmFile)),
      hostEnv,
    )

    return wasmMod.instance.exports
  }

// Run all the unit tests
startWasm(wasmFilePath)
  .then(wasmExports => {
    // SNIP
    wasmExports.should_return_choice()
  })

After testing has completed, the WASM unit-test wrapper functions should be commented out.

  1. As part of the optimisation process, this particular function was inlined and so does not appear in the final version of the code.