5.5. Testing#

Most students try to solve the complicated programs for this course by implementing them, and then manually testing their program. This is, possible…., but incredibly challenging. Proper unit and integration tests will, on the other hand, save you an enormous number of hours in this course.

5.5.1. Unit Tests#

As you develop your program, you are going to write many individual functions. For each, think about how to write simple tests to see if the function does what you expect it to. For example, one of the assignments we often hand out is a user level thread scheduler. To get it to work, you need to set up the stack of the thread properly so that when the thread completes it calls a routine to exit. If you write a simple test at the beginning to test what happens after a thread completes, it will take a few minutes to identify and fix any bugs. Many students, instead, spend hours trying to identify the problem manually when they have a running scheduler, with timer interrupts causing threads to switch between themselves.

Lets see some simple examples, we have already shown a script that runs a set of test programs (Listing 5.1), and a makefile (Listing 5.2)to invoke that script on a parser. The first step on developing a parser is to define its interface, and a set of tests against that interface. To do this, we define the data structures and functions that the parser will expose to its clients in a header file shown in Listing 5.3.

Listing 5.3 Header file for parser for shell#
 1#ifndef MYSHELL_PARSER_H
 2#define MYSHELL_PARSER_H
 3#include <stdbool.h>
 4
 5#define MAX_LINE_LENGTH 512
 6#define MAX_ARGV_LENGTH (MAX_LINE_LENGTH / 2 + 1)
 7
 8struct pipeline_command {
 9  char *command_args[MAX_ARGV_LENGTH]; // arg[0] is command, rest are arguments
10  char *redirect_in_path;  // NULL or Name of file to redirect in from 
11  char *redirect_out_path; // NULL or Name of a file to redirect out to
12  struct pipeline_command *next; // next command in the pipeline. NULL if done 
13};
14
15struct pipeline {
16  struct pipeline_command *commands; // first command
17  bool is_background; // TRUE if should execue in background
18};
19
20void pipeline_free(struct pipeline *pipeline);
21
22struct pipeline *pipeline_build(const char *command_line);
23
24#endif /* MYSHELL_PARSER_H */

In this header file, we define everything that test programs, and eventually the shell will need to use to call the parser we will develop. The routines pipeline_build returns a pipeline struct with a flag that indicates if the whole pipleline is in the background, and points to a linked list of pipeline_commands. Each pipeline_command contains a boolean indicates if the command is in the background, and an array where the first element is the command and the other elements are arguments to that command. Our first implementation of myshell_parser.c (see Listing 5.4) simply returns error messages.

Listing 5.4 Initial implementation of#
 1#include "myshell_parser.h"
 2#include "stddef.h"
 3
 4struct pipeline *pipeline_build(const char *command_line)
 5{
 6	// TODO: Implement this function
 7	return NULL;
 8}
 9
10void pipeline_free(struct pipeline *pipeline)
11{
12	// TODO: Implement this function
13}

Before implementing any functionality we write tests on what we expect a correct implementation to do. For example, Listing 5.5 shows a test that calls the parser pipeline_build with a single command ls, asserts what it expects the state of a correct execution of the parser is.

Listing 5.5 Test of a simple#
 1#include "myshell_parser.h"
 2#include <stdio.h>
 3#include <stdlib.h>
 4#include <string.h>
 5#include <assert.h>
 6
 7int
 8main(void)
 9{
10  struct pipeline* my_pipeline = pipeline_build("ls\n");
11  
12  // Test that a pipeline was returned
13  assert(my_pipeline != NULL);
14  assert(!my_pipeline->is_background);
15  assert(my_pipeline->commands != NULL);
16  
17  // Test the parsed args
18  assert(strcmp("ls", my_pipeline->commands->command_args[0]) == 0);
19  assert(my_pipeline->commands->command_args[1] == NULL);
20  
21  // Test the redirect state
22  assert(my_pipeline->commands->redirect_in_path == NULL);
23  assert(my_pipeline->commands->redirect_out_path == NULL);
24  
25  // Test that there is only one parsed command in the pipeline
26  assert(my_pipeline->commands->next == NULL);
27  
28  pipeline_free(my_pipeline);
29}

As another example, Listing 5.6 runs a simple example of two commands with a pipe between them.

Listing 5.6 Test of a simple pipe#
 1#include "myshell_parser.h"
 2#include <stdio.h>
 3#include <stdlib.h>
 4#include <string.h>
 5#include <assert.h>
 6
 7int
 8main(void)
 9{
10  struct pipeline* my_pipeline = pipeline_build("ls | cat\n");
11  
12  // Test that a pipeline was returned
13  assert(my_pipeline != NULL);
14  assert(!my_pipeline->is_background);
15  assert(my_pipeline->commands != NULL);
16  
17  // Test the parsed args
18  assert(strcmp("ls", my_pipeline->commands->command_args[0]) == 0);
19  assert(my_pipeline->commands->command_args[1] == NULL);
20  
21  // Test the redirect state
22  assert(my_pipeline->commands->redirect_in_path == NULL);
23  assert(my_pipeline->commands->redirect_out_path == NULL);
24  
25  // Test that there are multiple parsed command in the pipeline
26  assert(my_pipeline->commands->next != NULL);
27  
28  // keep going... what should we be testign next?
29  pipeline_free(my_pipeline);
30}

We have already provided examples of how you can invoke the unit test programs from a shell scripts (Listing 5.1), and shown how that can in turn by automatically invoked by make (Listing 5.2) so that every time you make a change the makefile will automatically re-run all the tests. Now, given the above test files, if we type make with that makefile, the all rule will run the check rule which will recompile any required software and then invoke the shell script to run all the tests. In our case, both test programs assert because the pipeline returned is NULL.

Unit tests are specific to the particular interfaces of the functions you write. We expect students to write their own unit tests, since the internal functions of their application are up to them to design. For the parser, we have defined the functions that we want you to implement, so that we can give you some example of unit tests. However, we would recommend that a good parser implementation should start by implementing a lexer, that converts each syntactical element of the input into a set of tokens. If you do that, you should add the new interface to the .h file and new tests to make sure that your lexing functionality works.

You should never delete your tests. For example, the parser will eventually become the first shell assignment for many courses based on this book. You will keep adding new functionality, and as you do, you will find that you will find bugs in your parser. If your makefile doesn’t keep running all the old tests, you are very likely to be introducing bugs…

5.5.2. Integration Tests#

Integration tests use the public interfaces end-to-end. For example, the tests we run with gradescope to automatically test if your programs are correct are all integration tests. We hope you will share your integration tests with the class, and in the BU version of the course, we will often add tests provided by students to the test suite we use in gradescope; the best way you can have tests you know you will pass is to contribute tests to us. In many cases, you can write integration tests in the same we described above, but calling the public interfaces of libraries. If the task is to develop a program, you can use, for example, shell scripts, with the output redirected to a file, to run tests, compare the result to an expect result, and raise an error if the results do not agree with the expected ones.