Skip to content

Unit Testing Exercise

The objective of this exercise is to allow you to practice and get used to unit testing as a general concept.

Note

Knoweldge of build systems will be useful here but not required.

1. Selecting Your Testing Framework

Before starting with the actual project setup we can decide on the testing framework we want to use. It was mentioned that there is a variety of testing frameworks and environments to choose from. Instead of locking you into one particular framework, it would be good for you to experiment and get exposure to many different frameworks.

For this step take a look at least three different testing frameworks and pick one to use for your project. As a general recommendation, feel free to consider Unity, Google Test (GTest), CMocka, and or CppUTest

NOTE: While you can effectively do the language in any programming languae, it is expected that you will use C/C++ and choose test frameworks that support those two languages.

Note

As you are evaluating different languages, pay attention to the syntax, how the testing environment is setup, and how test results are validated.

2. Project & Environment Setup

In whatever development environment you prefer to use, create a new project and integrate your testing environment of choice. Because there are so many different IDEs, text editors, and possible configurations we will not provide a dedicated guide for this step. However, here are some markers that you can use to know if you have everything properly setup.

  1. You can compile a simple hello world example. This may seem like an insignificant item but it makes a surprisingly good litnus test to ensure a basic project setup. This is also a good idea if you plan to use a custom build system such as CMake or Meson

  2. You should be able to download most testing frameworks from either their respective webpages or from a dedicated github releases page. Alongside the release, some will include instructions for integrating with a IDE or specific build system. Feel free to cheat a little bit here by copying files directly into your project directory :)

  3. Most testing frameworks provide an example test case you can usually copy or integrate into your project. If you can be mostly confident in moving onto the next step.

3. Fixing some source code.

To start, we shall give you the test cases and have you implement the functionality for the specific function under test. To keep this simple, we shall mostly deal with bitwise functions which have sufficient complexity in both implementation and validation.

Let's consider a function that has the following attributes/requirements.

  • Accepts a 32 bit/4-byte integer and two 16 bit integer pointers as arguments
  • Does not return anything (has a void return type)
  • Splits the 32 bit integer into two halves, write the lower 2 bytes to the pointer specified by the first input argmument and the upper 2 bytes to the pointer specified by the second argument

Note

As an aside, being comfortable with bitwise operations is a good skill to develop. Many embedded software/developer positions tend to ask questions utilizing some form of bitwise operation in technical interviews and you may use them on the job!

To keep consistency, we are providing a function declaration which you will call in the test cases.

void split_32bit_integer(uint32_t input, uint16_t *part1, uint16_t *part2);

Below are the test cases written with respect to a few different unit test frameworks.

#include "unity.h"

void test_split_32bit_integer(void) {
    uint16_t part1, part2;

    split_32bit_integer(0xFFFFFFFF, &part1, &part2);
    TEST_ASSERT_EQUAL_UINT16(0x03FF, part1);
    TEST_ASSERT_EQUAL_UINT16(0x03FF, part2);

    split_32bit_integer(0x00000000, &part1, &part2);
    TEST_ASSERT_EQUAL_UINT16(0x0000, part1);
    TEST_ASSERT_EQUAL_UINT16(0x0000, part2);

    split_32bit_integer(0xABCDEF12, &part1, &part2);
    TEST_ASSERT_EQUAL_UINT16(0x02AF, part1);
    TEST_ASSERT_EQUAL_UINT16(0x03BC, part2);
}

int main(void) {
    UNITY_BEGIN();
    RUN_TEST(test_split_32bit_integer);
    return UNITY_END();
}
#include <gtest/gtest.h>

TEST(Split32BitIntegerTest, HandlesMaxValue) {
    uint16_t part1, part2;
    split_32bit_integer(0xFFFFFFFF, &part1, &part2);
    EXPECT_EQ(0x03FF, part1);
    EXPECT_EQ(0x03FF, part2);
}

TEST(Split32BitIntegerTest, HandlesZeroValue) {
    uint16_t part1, part2;
    split_32bit_integer(0x00000000, &part1, &part2);
    EXPECT_EQ(0x0000, part1);
    EXPECT_EQ(0x0000, part2);
}

TEST(Split32BitIntegerTest, HandlesArbitraryValue) {
    uint16_t part1, part2;
    split_32bit_integer(0xABCDEF12, &part1, &part2);
    EXPECT_EQ(0x02AF, part1);
    EXPECT_EQ(0x03BC, part2);
}

int main(int argc, char **argv) {
    ::testing::InitGoogleTest(&argc, argv);
    return RUN_ALL_TESTS();
}
#include <stdarg.h>
#include <stddef.h>
#include <setjmp.h>
#include <cmocka.h>

static void test_split_32bit_integer(void **state) {
    (void) state;
    uint16_t part1, part2;

    split_32bit_integer(0xFFFFFFFF, &part1, &part2);
    assert_int_equal(0x03FF, part1);
    assert_int_equal(0x03FF, part2);

    split_32bit_integer(0x00000000, &part1, &part2);
    assert_int_equal(0x0000, part1);
    assert_int_equal(0x0000, part2);

    split_32bit_integer(0xABCDEF12, &part1, &part2);
    assert_int_equal(0x02AF, part1);
    assert_int_equal(0x03BC, part2);
}

int main(void) {
    const struct CMUnitTest tests[] = {
        cmocka_unit_test(test_split_32bit_integer),
    };
    return cmocka_run_group_tests(tests, NULL, NULL);
}

Before proceeding do the following:

  1. Make an empty or "dummy" implementation of the bitwise operation first. It should look similar to the function declaration below:

    void split_32bit_integer(uint32_t input, uint16_t *part1, uint16_t *part2)
    {
        /* Does absolutely nothing */
        return;
    }
    
  2. Copy and or implment the test cases provided once without implementing the functions. You should see all tests fail. If you do not, something has gone wrong and we may need to double check your project setup.

    Todo

    For Miles, insert picture of all test cases failing.

  3. After seeing all tests initially fail, complete the function. When you have implemented the function correctly, all unit tests should pass.

Note

Run/execute the tests frequently as you complete your code. Sometimes your changes can cause one test to pass and another fail.

DO NOT MODIFY THE TEST CASES! The point of this part of the exercise is just for you to see how a unit test works and what to expect when working with one.

4. Writing the Tests - Your Turn

More often than not you'll be on the side of writing code to satisfy the requirements and tests. However, you may find yourself on the other side where you have to write the test cases as well. It's your turn to write some test cases.

Let's say you have a function that converts a 32 bit integer from little endian to big endian notation and returns the resulting conversion. Let's add the following conditions/constraints:

  • The function can only accept a single 32 bit integer argument
  • The input argument must be greater than 0xA1FA but less than 0x0XDEADBEEF
  • If the input falls in an invalid range, return -1
  • Assume the input is always in little endian notation
  • An input value matching the lower/upper bounds of the function (0xA1FA, 0xDEADBEEF) should be considered valid

We are providing the function definition and declaration below but you are welcome to create your own:

int32_t convert_little_to_big_endian (uint32_t little_endian_input);

int32_t convert_little_to_big_endian (uint32_t little_endian_input) {
    uint32_t big_endian_value = ((little_endian_input >> 24) & 0x000000FF) |
                                ((little_endian_input >> 8)  & 0x0000FF00) |
                                ((little_endian_input << 8)  & 0x00FF0000) |
                                ((little_endian_input << 24) & 0xFF000000);

    if (big_endian_value < 0xA1FA || big_endian_value > 0xDEADBEEF) {
        return -1;
    }

    return big_endian_value;
}

Create test cases that test the function under the following conditions:

  1. The input value that is within the bounds
  2. The input value that is at the upper bound
  3. The input value that is at the lower bound
  4. The input value that is significantly greater than the upper bound
  5. The input value that is significantly less than the lower bound

Note that the expected results/behavior of the function and test case shall change based on the input. If you pass in a value outside of the expected range, you should expect a specific return value.