<- function(number) {
add_one <- number + 1
number return(number)
}
<- 5
number_n = add_one(number_n) number_n_plus_one
Unit Testing in R
1 What is the purpose of functions?
Functions should:
- split up the tasks in the code.
- not be too small or too specific.
- not contain entire programs.
Functions that are too small or specific can be unnecessary; you don’t need to create functions for single line operations.
It would be much clearer to just + 1
to the variable rather than using a function.
Putting entire programs, or large scripts into a function to call it makes the code hard to generalise and difficult to maintain.
This topic is intended for those interested in improving their functions, but not essential for beginners to implement.
Where possible we want to write functions that are deterministic and pure.
1.1 Deterministic Functions
For a given input there is a fixed output.
You can think of this like a mathematical function f(X) = y
, where f
is our function and X
is the input. For a given input of parameters X
into function f
there is a fixed output or outcome y
.
We want functions that do what we think they will do, so the result of any given input can be known. If this is not the case we are unable to expect the result of a function, and this will impact how we allow it to interact with other parts of our code base.
Below are examples of non-deterministic and deterministic functions that add an integer to an input number.
(They are simple examples for demonstration purposes, you would not write these functions in reality as shown by the previous section.)
<- function(initial_integer) {
add_single_integer <- sample(0:10, 1)
integer_to_add <- initial_integer + integer_to_add
sum_of_integers return(sum_of_integers)
}
add_single_integer(5)
We cannot determimine what the returned value for add_single_integer(5)
will be.
Deterministic
<- function(initial_integer) {
add_single_integer <- 7
integer_to_add <- initial_integer + integer_to_add
sum_of_integers return(sum_of_integers)
}
add_single_integer(5)
[1] 12
We can predict what the value of add_single_integer(5)
will be, or what the outcome of add_single_integer(initial_integer)
will be for any reasonable input.
1.2 Pure Functions
Pure functions - are deterministic functions whose outputs don’t depend on variables that are not inputted into the function. For example, a pure function is not dependent on reading or writing a file, and it will not change the values of external variables.
If the output of our function is dependent on factors that are not input into the function then we cannot guarantee what the output or effect of the function will be. Nor do we want a function to impact other states in the program without our explicit request.
<- "Global"
string_to_add
<- function(initial_string){
combine_string <- paste(initial_string, string_to_add, sep="")
new_string return(new_string)
}
combine_string("Argument")
[1] "ArgumentGlobal"
We can determimine what the returned value for combine_string("Argument")
will be, but the value of string_to_add
could change and that will impact what our function does without us explicity telling it to. We cannot determine what the returned value is based only on the inputs of the function, as it depends on external variables.
<- function(initial_string, string_to_add){
combine_string <- paste(initial_string, string_to_add, sep="")
new_string return(new_string)
}
combine_string("Argument_first", "Argument_second")
[1] "Argument_firstArgument_second"
We can now determine what the the returned value will be when given the input arguments for any reasonable inputs.
These concepts are quite theoretical, the important takeaway are:
Will your function do what you expect it to?
Will your function impact, or be impacted by other parts of your program unintentionally?
It’s then up to you whether you want this to be the case.
There are some obvious exceptions to the goal of deterministic and pure functions. If a function requires random number generators to work it will not be deterministic. Functions that read and write files are necessary, but not pure. We want to make it clear when this is the case.
1.3 The Mean Function
Below is the initial version of our new function arithmetic_mean()
to find the mean of a list/vector of numbers.
<- function(input_vector) {
arithmetic_mean <- sum(input_vector)
value_sum <- length(input_vector)
number_of_values <- value_sum / number_of_values
mean_value return(mean_value)
}
<- c(1, 2, 3)
test_vector
arithmetic_mean(test_vector)
[1] 2
1.4 Making Functions with Clarity
As with all code it is important that what we write is clean and what it does is clear. The benefits are numerous; it is easier for you and others to understand, it is therefore easier to maintain, and easier to find bugs within.
These benefits are also true when writing functions. We want them to be clean, simple and what they do obvious. Without going through language specific syntax there are some principles to keep in mind.
Much of this content is generally described in programming language style guides, but the ideas are included in this course to point to relevant concepts to unit testing.
R does not have one single agreed upon style guide, but a commonly used one is the Google Style Guide. This is linked to here.
This guide is definitely worth a read at some point if you are trying to write better code.
Naming Functions
What the function does should be clear from it’s name. The reader should be able to understand the purpose of the function by just seeing it called, which makes code easier to understand. For example, if we have a function that cleans a dataframe:
We do not want names too short and unclear about their purpose such as:
cdf()
We don’t want functions that are too long and confusing:
cleanDataframeByFillingMissingValuesButAlsoLowercasingAndReformattingTheColumns()
When something like cleanDataframe()
would suffice. We can then clearly show how it does that in the function itself and documentation.
This idea follows on directly from the best practice in variable naming. We avoid names that:
- are too short and non-descriptive (
h
) - are misleading or irrelevantly named
- are very generic (
This
,That
,The_Other
) - conflict or nearly conflict with the langauges base names
- do not follow the same style as the rest of our code
Documentation
What your code is doing should be clear from the naming of variables.
It is useful to include comments as to why the code is doing what it is doing to help others understand it. Your code itself explains what it is doing, especially when it is clearly written.
What your comments add are what the code cannot tell the reader. Your comments can give context to those who didn’t write the code (and yourself down the line) which is invaluable. The comments can detail why you chose a certain approach which the code cannot explain.
Your code itself should strive to be self documenting, but it will never be completely. So write comments that fill in the gaps.
In addition to general documentation, functions should contain an additional level of description. A package called roxygen2 is used to add description to the function.
For each language there is information we want to include in order to properly describe a function (where applicable):
- What the function does.
- What arguments the function takes.
- The return values the function produces.
- The side effects produced when invoked.
- The exceptions involved (more on these later!).
Documentation Example
Again, we are going to use our arithmetic_mean()
function to demonstrate how this works.
R uses roxygen2
to produce a documentation file for packages. This is beyond the scope of this course, but we will use the syntax of this method to document our functions.
# eval: false
library(roxygen2)
Warning: package 'roxygen2' was built under R version 4.4.2
#' Calculates the mean value of an input
#' vector of numbers.
#'
#' @param input_vector the vector of numbers the mean
#' is calculated from.
#'
#' @return numeric with the value of the mean.
#'
#' @examples
#' arithmetic_mean(c(1, 2, 3))
<- function(input_vector) {
arithmetic_mean <- sum(input_vector)
value_sum <- length(input_vector)
number_of_values <- value_sum / number_of_values
mean_value return(mean_value)
}
Clear Logic
There are many aspects to writing clear and logical code.
One of these is to ensure that there is a flow in how the code is designed.
Where possible the program’s statements are grouped naturally according to what they do and organised sequentially wherever possible. This is encouraged as regardless of how code is executed, the reader is likely to be reading sections from the top down.
Another aspect of having clear logic is to ensure that code within a function is not unnecessarily opaque, nor is it short at the expense of readability and flow.
There are quirks and features of R
that make code shorter. These are useful and powerful tools, but should be used sparingly if they make the purpose of the code less clear.
The T
and F
variables are shorthands and evaluate to TRUE
and FALSE
. But why use them when TRUE
is clearer than T
?
There is no hard and fast rules for when to or not to use these sorts of features, but consider “Is my code more or less simple to read because of what I have done here?”
When first drafting code the resulting product may contain redundant parts, these are prime segments to remove/restructure which will increase the clarity of the code.
The best code is simple, if working code is written such that it cannot easily be read or takes significant effort to understand then it is not well written code.
Key point: write code that you can come back to in a few years and understand quickly.
You are doing your future self and others an important favour in writing legible and clearly constructed functions.
Logic Example
Here are some examples of complex statements and what they can be reduced to, which gives the reader less mental overhead, allowing more energy going into fixing whatever bug has been found.
Try out these two statements in a script of your own, assigning True/False values to boolean_variable
in order to check whether or not the statements return the same values.
The principle of reducing complex logic where possible will save you and your colleagues time in the future.
<- TRUE
boolean_variable
# Long
if (!boolean_variable == FALSE) {
print(boolean_variable)
}
# Clear
if (boolean_variable == TRUE) {
print(boolean_variable)
}
# Shortest
if (boolean_variable) {
print(boolean_variable)
}
Appropriate Tasks
Functions should do one thing, and do it well. It is important when progressing through the software development cycle to be restructuring/rewriting your code, breaking it up where necessary and rewriting inefficient features. This helps to prevent unwieldy large functions from being produced.
In general we want each function to have one purpose, and for it to follow the Single Responsibility Principle. This means the function only needs to perform one task for the program.
There is no hard or fast rule regarding the size of a function, but by breaking down and making functions for tasks within existing functions, the clarity, modularity and testability increases.
Task Splitting Example
The following example shows an appropriate time to separate one function into multiple. The calculate_sd()
function returns the standard deviation of a list/vector of numbers.
This way we can now use the arithmetic_mean()
function in other areas of our code too, which is an additional bonus to making our code clearer.
This is a simple example to demonstrate how to rewrite functions in order to make them clearer and to have explicit purpose. The new functions can then be used by other processes in the code, and can be debugged separately.
#' Calculates the standard deviation value of an input
#' vector of numbers.
#'
#' @param input_vector the vector of numbers the standard deviation
#' is calculated from.
#'
#' @return numeric with the value of the standard deviation.
#'
#' @examples
#' calculate_sd(c(1, 2, 3))
<- function(input_vector) {
calculate_sd
<- sum(input_vector)
value_sum <- length(input_vector)
number_of_values <- value_sum / number_of_values
mean_value
<- (input_vector - mean_value)^2
difference_squared <- sum(difference_squared)
difference_squared_sum
<- difference_squared_sum / number_of_values
variance
<- variance^0.5
standard_deviation return(standard_deviation)
}
#' Calculates the mean value of an input
#' vector of numbers.
#'
#' @param input_vector the vector of numbers the mean
#' is calculated from.
#'
#' @return numeric with the value of the mean.
#'
#' @examples
#' arithmetic_mean(c(1, 2, 3))
<- function(input_vector) {
arithmetic_mean <- sum(input_vector)
value_sum <- length(input_vector)
number_of_values <- value_sum / number_of_values
mean_value return(mean_value)
}
#' Calculates the standard deviation value of an input
#' vector of numbers.
#'
#' @param input_vector the vector of numbers the standard deviation
#' is calculated from.
#'
#' @return numeric with the value of the standard deviation.
#'
#' @examples
#' calculate_sd(c(1, 2, 3))
<- function(input_vector) {
calculate_sd
<- arithmetic_mean(input_vector)
mean_value
<- (input_vector - mean_value)^2
difference_squared <- sum(difference_squared)
difference_squared_sum
<- difference_squared_sum / length(input_vector)
variance
<- variance^0.5
standard_deviation return(standard_deviation)
}
1.5 Parameter Validation
By creating a function you define what happens within it, however, it is not possible to completely control what may be input into your function.
There may be cases when “bad” values are accidentally (or not!) input into the function.
This is especially important in dynamically typed languages such as python
and R
, as the data type of a variable is undefined when assigned.
To prevent this type of issue becoming a problem parameter validation is used. This means that we want to check whether the input of a function is what is expected.
Defining what is expected is dependent on the program, but this is typically factors such as:
- the data type of the function argument (numeric, list, string and more).
- the value range of the function argument (for example, non-negative, length greater than 5).
- whether missing values are allowed in data.
Error handling should:
- Be able to handle all “bad” inputs into the function.
- Be informative as to what has occurred when an error is encountered.
- Be situated at an appropriate place in the code.
If our program is to fail due to bad inputs, we want it to fail properly and in an informative manner.
The parameter validation should occur as soon as the data enters the function, so unnecessary computation is avoided.
Example Syntax
Below is an example using the arithmetic_mean()
function to check whether our input data structure is what it should be, whether it contains any data, and whether that data is all numeric.
Try to run this new code with a range of inputs, does it raise exceptions?
In R you can use conditional statements followed by a stop
command that raises an error. This stops the execution of the program and informs the user why the process terminated.
Remember, the !
operator negates logic statements.
#' Calculates the mean value of an input
#' vector of numbers.
#'
#' @param input_vector the vector of numbers the mean
#' is calculated from.
#'
#' @error if the input_vector is not a vector
#' @error if the input_vector is zero length
#' @error if the input_vector is neither a numeric or integer vector
#'
#' @return numeric with the value of the mean.
#'
#' @examples
#' arithmetic_mean(c(1, 2, 3))
<- function(input_vector) {
arithmetic_mean if (!is.vector(input_vector)) {
stop("Input must be a vector")
}if (length(input_vector) == 0) {
stop("Input vector must be non-zero length")
}if (!(is.numeric(input_vector) | is.integer(input_vector))) {
stop("Input vector must be a numeric or integer")
}<- sum(input_vector)
value_sum <- length(input_vector)
number_of_values <- value_sum / number_of_values
mean_value return(mean_value)
}
2 Writing Unit Tests
Now that we are writing effective functions, we can test units of our code.
Unit tests are an automated way for us to test that our function does what we think it should do.
In general, we input an argument into a function, define what we think should happen as a result, and compare that with what actually does happen.
Whether this is an effective way to measure the quality of our code is dependent on the quality of the unit tests we write. The unit tests must:
- Be accurate (does what we think it does)
- Account for all possible behaviours of the function (different parameters, values, data types)
- Be independent of one another (doesn’t rely on other tests)
Good and comprehensive unit tests allow the developer to be confident that the code performs as expected.
2.1 File Convention and Packages
There are many ways to organise the tests in different files. It is generally convention for each function tested to have it’s own script of tests, and if necessary, each type of test to have a separate script.
Within the /content/
folder there is a folder /content/unit_testing_practice/
. Navigate in file explorer to this location. In the file there are two .r
files that are relevant. The first, functions.r
, will contain the functions we want to test. The second, test_functions.r
, will contain the tests we will run.
functions.r
contains the arithmetic_mean(input_vector)
function below. Perform a manual test (run it with some input) so you are certain it has been copied over correctly.
OPEN RSTUDIO and check the below code is present, run the function with some argument to check it runs
#' Calculates the mean value of an input
#' vector of numbers.
#'
#' @param input_vector the vector of numbers the mean
#' is calculated from.
#'
#' @error if the input_vector is not a vector
#' @error if the input_vector is zero length
#' @error if the input_vector is neither a numeric or integer vector
#'
#' @return numeric with the value of the mean.
#'
#' @examples
#' arithmetic_mean(c(1, 2, 3))
<- function(input_vector) {
arithmetic_mean if (!is.vector(input_vector)) {
stop("Input must be a vector")
}if (length(input_vector) == 0) {
stop("Input vector must be non-zero length")
}if (!(is.numeric(input_vector) | is.integer(input_vector))) {
stop("Input vector must be a numeric or integer")
}<- sum(input_vector)
value_sum <- length(input_vector)
number_of_values <- value_sum / number_of_values
mean_value return(mean_value)
}
The chosen method for unit testing in R
is using the testthat
package. It contains a range of functions and objects that aid in unit test development.
In order to install testthat
on an ONS machine, type the below command into the console:
install.packages("testthat", dependencies = TRUE, type = "win.binary")
The package must be imported in the script file using library(testthat)
You will need to import it into the console to run the tests.
Using Rstudio go to Session > Set Working Directory > To source File Locations. This will change your working directory to where we need it.
R tests can be run using either the test_file(path)
or test_dir(path)
commands in the console. The path
argument indicates the path to the folder containing the test scripts, which should be named test_<name>.R
. The name of what is being tested can be included at the top of the script, or before a testing function using context("<description>")
.
The steps to design and run unit tests in this course are:
- Create your functions to test in the working directory.
- Create the unit tests in scripts located in the working directory.
- Run
test_dir("./")
in the console.
This is done in the console part of RStudio by typing:
> test_dir("./")
This is what will run your written tests for you.
Do not write test_dir("./")
in your script, only in the console.
The "./"
file path indicates that you are running the tests from your current directory.
- Analyse the results from the tests
Running the test_dir
command should now show that you have no tests written yet
2.2 Checking Returned Values
This section uses the arithmetic_mean()
function as a subject for testing.
One of the most useful tests we can do that checks whether our functions is returns the value we are expecting.
This is crucial as we are able to take known results and ensure that the output of our function meets those values.
Add the tests we work through to your test_functions.R
file so you can check the results.
In R
we call a function called expect_equal()
which will be a unit test. This is what is called when we run test_dir(path)
later.
An expect_
statement is the fountation of unit testing in R
, it makes up all unit tests.
In order to check a returned value is as expected we pass two arguments into the expect_equal
function that we… expect to be equal to one another. If they are equal, the test passes, if not the test fails.
We therefore typically write something of the form expect_equal(function(input), expected_value)
, although the order does not matter.
The context("describe context")
line allows us to group tests with the same “context” - a description.
# Loading the library we want to use
library(testthat)
# Loading in the file containing the function we
# want to test allows us to call it in a different script.
source("functions.R")
context("All ones")
expect_equal(arithmetic_mean(c(1, 1, 1, 1)), 1)
Then call test_dir("./")
in the console while ensuring we are in the correct working directory, this runs our tests producing:
This gives us a detailed description of the outcomes of our tests.
Creating a test that will fail such as the code below will produce a “Failed” test.
library(testthat)
source("functions.R")
context("All ones")
expect_equal(arithmetic_mean(c(1, 1, 1, 1)), 1)
context("All twos")
expect_equal(arithmetic_mean(c(2, 2, 2, 2)), 3)
This results in an output of:
The results are informative about where our test failed, showing that the two values were not equal.
expect_equal()
is actually a shortcut for a more general function used in testthat
. The expect_that()
function allows a more flexible usage. It follows the form:
expect_that(input, does_something())
Therefore the expect_equal()
function can be written as:
expect_that(input, equals(input))
There are many other possible expectation statements that can be written, which we will explore later, such as:
expect_true(input) / expect_false(input)
expect_output(input, output)
expect_is(input, attribute)
expect_error()
Now you can change the All twos
context to check that the mean of [2, 2, 2, 2] equals 2
.
2.3 Check Variable Types
Similarly to the previous section, we can check other attributes of what the function returns.
One such example is the data type returned. As our languages are “dynamically typed” we cannot guarantee what type will be produced at the end of the function’s execution, therefore it makes sense to test this.
The syntax for R
is similar to the previous examples of checking values, except we add another layer of logic to the statements involved.
Add the tests we work through to your test_functions.R
file so you can check the results.
In this example we are going to use the expect_equal()
function to create unit tests for data types. We are going to pass the data type of the returned value from the function and compare it to the expected data type. In this case we are anticipating a “float” type.
In order to check what a data type is we us typeof()
which returns a string.
We add the following tests to test_functions.R
.
context("Check Data Type")
expect_equal(typeof(arithmetic_mean(c(3.0, 4.0, 5.0))), "double")
expect_equal(typeof(arithmetic_mean(c(3L, 4L, 5L))), "double")
Our function passes these two new tests, as regardless of the numeric data type passed into the function R
will convert it to doubles in order to perform certain actions such as division.
2.4 Checking for Errors Raised
Whether the value of the output of a function is correct is different from whether or not the data returned is the correct type.
As the languages used are “dynamically typed” we cannot guarantee the type of a variable, an incorrect data type could pass improper data from one process to the next.
Here is an overview of what “dynamically typed” means if you are interested in learning more about how programming languages work. In short, it means that we don’t explicity state variable types when assigning a variable.
We are going to check that our function produces appropriate errors when the wrong data type is passed to the function.
Well built functions will contain processes for generating proper errors when parameters are not valid, it is important to be able to check these errors at the right times.
Add the tests we work through to your test_functions.R
file so you can check the results.
Much like the expect_that
and expect_equal
functions given by the teststhat
package we are able to use expect_error
in order to test whether a specific error is raised.
The first argument of the expect_error
function calls the function we want to test, and the second is the error we are expecting to receive as a string.
expect_error
is able to use regular expressions to accept partially matching character strings, this is useful, however we are going to use explicit direct matches only by adding in the parameter fixed = TRUE
. Because we defined what we want our error to output earlier, we are going to make sure to use the same string as what we are expecting the function to output.
We can create new tests to ensure our expected errors are produced.
library(testthat)
source("functions.R")
context("Value Checks")
expect_equal(arithmetic_mean(c(1, 1, 1, 1)), 1)
expect_equal(arithmetic_mean(c(2, 2, 2, 2)), 2)
context("Check Data Type")
expect_equal(typeof(arithmetic_mean(c(3.0, 4.0, 5.0))), "double")
expect_equal(typeof(arithmetic_mean(c(3L, 4L, 5L))), "double")
context("Raising Errors")
expect_error(arithmetic_mean(matrix()),
"Input must be a vector", fixed = TRUE)
expect_error(arithmetic_mean(vector()),
"Input vector must be non-zero length",
fixed = TRUE)
expect_error(arithmetic_mean(
c("not a numeric", "not an integer")),
"Input vector must be a numeric or integer",
fixed = TRUE)
These new tests can be added to test_functions.R
and called using test_dir("./")
. Our new tests are all passed by the arithmetic_mean()
.
To show what happens without our exceptions we can comment out the code and run our tests again.
The code without the relevant exceptions is given below.
#' Calculates the mean value of an input
#' vector of numbers.
#'
#' @param input_vector the vector of numbers the mean
#' is calculated from.
#'
#' @return numeric with the value of the mean.
#'
#' @examples
#' arithmetic_mean(c(1, 2, 3))
<- function(input_vector) {
arithmetic_mean #if (!is.vector(input_vector)) {
# stop("Input must be a vector")
#}
#if (length(input_vector) == 0) {
# stop("Input vector must be non-zero length")
#}
#if (!(is.numeric(input_vector) | is.integer(input_vector))) {
# stop("Input vector must be a numeric or integer")
#}
<- sum(input_vector)
value_sum <- length(input_vector)
number_of_values <- value_sum / number_of_values
mean_value return(mean_value)
}
This causes the function to pass the original four tests and fail the new three. The failures are shown below.
We can see that the function does not pass the three tests that we commented out the exceptions for.
The first two failures are because the function does not throw up an error for the given inputs. This shows why it is important to validate our inputs.
The third failure throws up a different error from what is expected. This is because somewhere in our function we are passing a character vector to another function which cannot take characters as an input.
2.5 Multiple Parameter Tests
As time goes on we will want to restructure/rewrite and improve our code.
We can also change our unit tests to ensure there is greater coverage and therefore confidence in our code.
So far we have tested similar properties of our functions using separate discrete tests. We can combine tests that have the same structure in order to test more cases quickly and clearly.
We are now going to rewrite all of the previous tests into a more succinct manner. We will replace the old tests with the new versions in test_functions.py
/test_functions.R
.
For each type of test there is a new test included. It is much easier to keep adding tests when we have set up the structure.
testthat
does not contain a pre-made parameterization process. This means that we cannot necessarily utilise the structure of a test function to automate the testing. However, we can reorganise our tests for clarity as shown below.
The syntax we are going to use will call the test_that
function. The first argument contains the name of our tests and the second is a group of statements contained within {}
.
We add one more test per type to show how to increase the test coverage of our suite.
library(testthat)
source("functions.R")
context("Mean Value")
test_that("arithmetic_mean produces mean value", {
expect_equal(arithmetic_mean(c(1, 1, 1, 1)), 1)
expect_equal(arithmetic_mean(c(2, 2, 2, 2)), 2)
expect_equal(arithmetic_mean(c(-2, -1, 0, 1, 2)), 0)
})
context("Mean Data Type")
test_that("arithmetic_mean returns the correct data type", {
expect_equal(typeof(arithmetic_mean(c(3.0, 4.0, 5.0))), "double")
expect_equal(typeof(arithmetic_mean(c(3L, 4L, 5L))), "double")
expect_equal(typeof(arithmetic_mean(c(1.0001, 0.9999, 1L))), "double")
})
context("Mean Error production")
test_that("arithmetic_mean produces appropriate errors", {
expect_error(arithmetic_mean(matrix()),
"Input must be a vector", fixed = TRUE)
expect_error(arithmetic_mean(vector()),
"Input vector must be non-zero length",
fixed = TRUE)
expect_error(arithmetic_mean(c("not a numeric", "not an integer")),
"Input vector must be a numeric or integer",
fixed = TRUE)
})
3 Exercises
The below exercises will help you consolidate the knowledge presented in this course, allowing you to perform basic unit testing in your own work.
3.1 Writing Tests
Using the information in this course you can now write your own unit tests relevant to your team’s projects. While it is advised to write tests as you add new features, or preferably before, sometimes you will want to check existing code’s functionality. This exercise will give you practice in writing unit tests to check code behaviour.
You are working in a data science team with a project on text data. There is a function in the code base called string_cleaning()
. The function takes an input of a string of characters and should output the input text with:
- leading and trailing whitespace removed
- the text lowercased
- any punctuation removed
The team is unsure as to whether the function is actually doing what it is supposed to, as slight differences in what this function produces will impact the project down the line. You have been tasked with writing unit tests to ensure that that the function produces the expected outputs.
The unit tests should check that:
- A Suitable error message (
R
) is raised when something other than a string of characters is in passed into the function. - There is no leading whitespace returned
- There is no trailing whitespace returned
- All characters in the string returned are lowercase
- There is no punctuation in the returned string
- The function returns a string
The function to test is contained in the text_processing.R
file, write your tests in test_text_processing.R
and change your working directory to /exercises/writing_tests/
.
You should write at minimum 15 unit tests total to complete this task.
The syntax of your answers may be different to those below and still be correct! You must be sure that your tests check the behaviours you want.
library(testthat)
source("text_processing.R")
context("string_cleaning Test lowercasing")
test_that("string_cleaning returns lowercased characters", {
expect_equal(string_cleaning("ALLCAPS"), "allcaps")
expect_equal(string_cleaning("Onecap"), "onecap")
expect_equal(string_cleaning("rAnDoMcApS"), "randomcaps")
})
context("string_cleaning Test whitespace")
test_that("string_cleaning returns with whitespace removed", {
expect_equal(string_cleaning(" leading"), "leading")
expect_equal(string_cleaning("trailing "), "trailing")
expect_equal(string_cleaning(" both "), "both")
expect_equal(string_cleaning(" long "), "long")
expect_equal(string_cleaning(" short "), "short")
})
context("string_cleaning Test punctuation removed")
test_that("string_cleaning removes punctuation", {
expect_equal(string_cleaning(","), "")
expect_equal(string_cleaning(":"), "")
expect_equal(string_cleaning("hi!"), "hi")
expect_equal(string_cleaning("&pointer"), "pointer")
expect_equal(string_cleaning("will this be removed?"), "will this be removed")
expect_equal(string_cleaning("'quote'"), "quote")
expect_equal(string_cleaning("%%%%%%%%the"), "the")
expect_equal(string_cleaning("/n"), "n")
})
context("string_cleaning Test Error Raised")
test_that("string_cleaning raises correct error", {
expect_error(string_cleaning(1),
"Input must be a character",
fixed = TRUE)
expect_error(string_cleaning(list()),
"Input must be a character",
fixed = TRUE)
expect_error(string_cleaning(c(1, 2)),
"Input must be a character",
fixed = TRUE)
expect_error(string_cleaning(NULL),
"Input must be a character",
fixed = TRUE)
})
3.2 Test driven development
This exercise is an example of how you may choose to write programs, by first specifying the expectations fo your code, then writing units that pass those tests.
Your task is to rewrite the calculate_sd()
function in the file ./exercises/test_driven_development/standard_deviation.R
such that it passes all the tests contained within the ./exercises/test_driven_development/
folder under the file name test_standard_deviation.(R)
. You will need to change your working directory to the ./exercises/test_driven_development/
folder.
Run the tests first and use the Failures to work out what you need to do to get the function to perform to the test’s specifications.
The following functions are examples of ways to pass the shown unit tests, can you write a better function?
#' Calculates the mean value of an input
#' vector of numbers.
#'
#' @param input_vector the vector of numbers the mean
#' is calculated from.
#'
#' @error if the input_vector is not a vector
#' @error if the input_vector is zero length
#' @error if the input_vector is neither a numeric or integer vector
#'
#' @return numeric with the value of the mean.
#'
#' @examples
#' arithmetic_mean(c(1, 2, 3))
<- function(input_vector) {
arithmetic_mean if (!is.vector(input_vector)) {
stop("Input must be a vector")
}if (length(input_vector) == 0) {
stop("Input vector must be non-zero length")
}if (!(is.numeric(input_vector) | is.integer(input_vector))) {
stop("Input vector must be a numeric or integer")
}<- sum(input_vector)
value_sum <- length(input_vector)
number_of_values <- value_sum / number_of_values
mean_value return(mean_value)
}
#' Calculates the standard deviation value of an input
#' vector of numbers.
#'
#' @param input_vector the vector of numbers the standard deviation
#' is calculated from.
#'
#' @error if the input_vector is not a vector
#' @error if the input_vector is zero length
#' @error if the input_vector is neither a numeric or integer vector
#'
#' @return numeric with the value of the standard deviation.
#'
#' @examples
#' calculate_sd(c(1, 2, 3))
<- function(input_vector) {
calculate_sd if (!is.vector(input_vector)) {
stop("Input to calculate_sd must be a vector")
}if (length(input_vector) == 0) {
stop("Input to calculate_sd vector must be non-zero length")
}if (!(is.numeric(input_vector) | is.integer(input_vector))) {
stop("Input to calculate_sd vector must be a numeric or integer")
}<- arithmetic_mean(input_vector)
mean_value <- (input_vector - mean_value)^2
difference_squared <- sum(difference_squared)
difference_squared_sum <- difference_squared_sum / length(input_vector)
variance <- variance^0.5
standard_deviation if (mean_value == 0) print("Your mean is zero!")
return(standard_deviation)
}
4 Summary
In this course we have delved into a range of theory and practical examples about good practice for writing functions and creating unit tests.
Before testing units of our code we need to be sure that the units (in our case functions) are well designed. There are many ways to design functions, but we have shown that some guiding principles can help. These include:
- Function docstrings
- Clear logic
- Single purpose of functions
- Validating parameters
By writing good functions we are therefore able to test the performance of these functions in a much easier way.
We have looked only at one type of software tests, the unit tests. These are the most simple, low level and fast genre of tests. We have only scratched the surface of what they can do.
This course introduced basic types of unit tests, these allow us to check elements of our codes performance such as:
- Returned Data Values
- Returned Data Types
- Errors Raised
- Parameterised Tests
This is not an extensive list of the power of Testing, but should be a start to how you can ensure your code does what it is meant to. The next section will introduce some new concepts that you can explore.
Please ensure you complete the post-course survey.
5 Further Study
This is a short course that has introduced some aspects of unit testing, but there are many more elements that you can add to your code to test different aspects.
5.1 Repository Structure
Tests often sit within a wider package of code so it is important to consider how best to structure our repositories.
The recommended approach is to use the usethis
package to set up your tests. You can use usethis::use_testthat(3)
to create a test/testthat
directory for your tests. Within this directory it is recommended that your test_*.R
files are organised to match the organisation of files in the R/
folder. Additionally, you can use usethis::use_test("name")
to open or create a file called test-name.R
. Further details on how to use this functionality can be found in the R packages documentation.
5.2 Testing Data Frames
Most of the testing we have looked at so far have been checking things related to built-in data types.
Often for analysis what we need to test is data frames and related data structures.
In R you will need to rely on the testthat
package works well with data frames as it is part of the tidyverse.
5.3 Assertions
In this course we have touched upon some good practice in writing functions, and how to test them. In the python section of this course we have briefly introduced assertions, but not used them to their full potential.
Assertions in programming can be a powerful, but lightweight way to ensure your code is performing as expected. In a similar way to how we raised errors when our data was not what we expected, we can use assertions throughout our code to check the data within is what is expected.
In R we can use the assertr
package of functions aid us in building a robust data pipelines with dataframes.
The assert()
function returns the data given to it if a specified function, called the “predicate” doesn’t return FALSE
when the data is passed to it.
Example of assertion passing
library(assertr)
Warning: package 'assertr' was built under R version 4.4.2
library(dplyr)
Warning: package 'dplyr' was built under R version 4.4.2
Attaching package: 'dplyr'
The following objects are masked from 'package:stats':
filter, lag
The following objects are masked from 'package:base':
intersect, setdiff, setequal, union
<- data.frame("ID" = c(0, 1, 2), "Letter" = c("A", "B", "C"))
test_data
<- test_data %>%
just_ID_data assert(not_na, ID) %>%
select(ID)
print(just_ID_data)
ID
1 0
2 1
3 2
Example of assertion NOT passing
library(assertr)
library(dplyr)
<- data.frame("ID" = c(NA, 1, 2), "Letter" = c("A", "B", "C"))
test_data
<- test_data %>%
just_ID_data assert(not_na, ID) %>%
select(ID)
print(just_ID_data)
Column 'ID' violates assertion 'not_na' 1 time
verb redux_fn predicate column index value
1 assert NA not_na ID 1 NA
We can see that there is missing data in this data frame, therefore the assertion error is raised. For more information about the assertr
package see here.
In addition to the assertr
package, there is the assertthat
package, which has similar properties to the python assertion system. It was written by the same author as the testthat
package, and therefore works well together. To look at the package on GitHub, see here.
For more information about assertions and defensize programming check here.
5.4 Advanced Errors
In this course we have only looked at basic errors in R
. We can raise different flags/explanations which allow more information about a program to be passed.
All of the errors we have produced so far have been custom errors, which allows us to have flexibility in what conditions we want to set. As well as errors we can also introduce messages and warnings which do not terminate the execution of our process.
The syntax for doing so is given below.
#' Calculates the division of two input numbers
#'
#' @param dividend first input value, to be divided by
#' @param divisor second input value, to divide by
#'
#' @warning if the divisor input is zero as you cannot divide by zero
#'
#' @return numeric with the value of the result of the operation
#'
#' @examples
#' divide(1, 2)
<- function(dividend, divisor) {
divide if(divisor == 0) {
warning("Divisor is zero, dividing by zero is not good!")
}return(dividend/divisor)
}
divide(10, 0)
Warning in divide(10, 0): Divisor is zero, dividing by zero is not good!
[1] Inf
The message
function performs a similar task, but by returning a message rather than giving a warning.
5.5 Widening Test Coverage
We have been able to manually improve our coverage of tests so far only by adding more tests ourselves, and py parameterising our tests, making it easier to expand the number of cases our tests check.
However, there are packages which can further increase our test coverage by allowing us to generate strategies for testing, which greatly increases the number of edge cases and situations we may not be able to spot ourselves.
One such package to do this in python is Hypothesis. The author is not at present aware of a R alternative.
5.6 Integration Tests
We determined that the purpose of unit tests was to check that the smallest element of code behaves as expected. This is good, but does not cover the entirety of what our code actually does.
Integration tests are tests which allow us to check the behaviour of how many of our units work together as a whole system. We can also use many of the same techniques used to test units of our code, but at a higher level of abstraction, with many elements working together.
Integration tests and unit tests work well together as the system is made of individual units, we can be confident of both the component parts, and how they work together.
5.7 Continuous Integration
It is not simply enough to have a test suite that we can call when we like in order to check the behaviour of our code based, ideally we would have a method to check our code in a more consistent manner.
Continuous Integration (CI) is the practice of merging small changes to our code base often. The opposite of this is a more ad hoc method, typical of less structured projects where large merges are added infrequently or at the end of the development cycle. Doing continuous integration allows the developers in a project immediate feedback on whether changes pass tests.
CI can be used for:
- Automated testing (running a test on each Gitlab commit/merge request)
- Style checking
- Generating code metrics
In development and analysis it is desirable to do CI as it allows:
- Faster development due to a smaller reliance on manual testing
- Fast, regular deployment
- Early bug detection
- Simple code governance
At the ONS one of the Continuous Integration services that is used is Jenkins, which is described in more detail here.