Mock versus hardware

An obvious question to ask when mocking away large sections of code and hardware peripherals is how realistic the resulting mock is. We obviously want to be able to cover as many real-life scenarios as possible with our integration test before we move to testing on the target system.

If we want to know which test cases we wish to cover in our mock, we have to look both at our project requirements (what it should be able to handle), and which situations and inputs can occur in a real-life scenario.

For this, we would analyze the underlying code to see what conditions can occur, and decide on which ones are relevant for us.

In the case of the WiringPi mocks we looked at earlier, a quick glance at the source code for the library's implementation makes it clear just how much we simplified our code compared to the version we would be using on our target system.

Looking at the basic WiringPi setup function, we see that it does the following:

  • Determines the exact board model and SoC to get the GPIO layout
  • Opens the Linux device for the memory-mapped GPIO pins
  • Sets the memory offsets into the GPIO device and uses mmap() to map specific peripherals such as PWM, timer, and GPIO into memory

Instead of ignoring calls to pinMode(), the implementation does the following:

  • Appropriately sets the hardware GPIO direction register in the SoC (for input/output mode)
  • Starts PWM, soft PWM, or Tone mode on a pin (as requested); sub-functions set the appropriate registers

This continues with the I2C side, where the setup function implementation looks like this:

int wiringPiI2CSetup (const int devId) { 
   int rev; 
   const char *device; 
    
   rev = piGpioLayout(); 
    
   if (rev == 1) { 
         device = "/dev/i2c-0"; 
   } 
   else { 
         device = "/dev/i2c-1"; 
   } 
    
   return wiringPiI2CSetupInterface (device, devId); 
} 

Compared to our mock implementation, the main difference is in that an I2C peripheral is expected to be present on the in-memory filesystem of the OS, and the board revision determines which one we pick.

The last function that gets called tries to open the device, as in Linux and similar OSes every device is simply a file that we can open and get a file handle to, if successful. This file handle is the ID that gets returned when the function returns:

int wiringPiI2CSetupInterface (const char *device, int devId) { 
   int fd; 
   if ((fd = open (device, O_RDWR)) < 0) { 
         return wiringPiFailure (WPI_ALMOST, "Unable to open I2C device: %s
", 
                                                                                                strerror (errno)); 
   } 
    
   if (ioctl (fd, I2C_SLAVE, devId) < 0) { 
         return wiringPiFailure (WPI_ALMOST, "Unable to select I2C device: %s
",                                                                                                strerror (errno)); 
   } 
    
   return fd; 
} 

After opening the I2C device, the Linux system function, ioctl(), is used to send data to the I2C peripheral, in this case, the address of the I2C slave device that we wish to use. If successful, we get a non-negative response and return the integer that's our file handle.

Writing and reading the I2C bus is also handled using ioctl(), as we can see in the same source file:

static inline int i2c_smbus_access (int fd, char rw, uint8_t command, int size, union i2c_smbus_data *data) { 
   struct i2c_smbus_ioctl_data args; 
 
   args.read_write = rw; 
   args.command    = command; 
   args.size       = size; 
   args.data       = data; 
   return ioctl(fd, I2C_SMBUS, &args); 
} 

This same inline function is called for every single I2C bus access. With the I2C device that we wish to use already selected, we can simply target the I2C peripheral and have it transmit the payload to the device.

Here, the i2c_smbus_data type is a simple union to support various sizes for the return value (when performing a read operation):

union i2c_smbus_data { 
   uint8_t byte; 
   uint16_t word; 
   uint8_t block[I2C_SMBUS_BLOCK_MAX + 2]; 
}; 

Here, we mostly see the benefit of using an abstract API. Without it, we would have peppered our code with low-level calls that would have been much harder to mock away. What we also see is that there are a number of conditions that we should likely be testing as well, such as a missing I2C slave device, read and write errors on the I2C bus that may result in unexpected behavior, as well as unexpected input on GPIO pins, including for interrupt pins as was noted at the beginning of this chapter already.

Although obviously not all scenarios can be planned for, efforts should be made to document all realistic scenarios and incorporate them into the mocked-up implementation, so that they can be enabled at will during integration and regression testing and while debugging.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.223.195.29