Unit Testing

14 November 2025

Writing good unit tests is hard

Goals

  • What is a unit, and what is a unit test?
  • Review the basic anatomy of a test, and how to structure your tests
  • How to structure your code to be easier to test
  • How to use mocks to replace complex dependencies

What is a unit anyway?

What is a unit anyway?

  • a unit is the smallest testable part of a program
  • typically, a unit is just a function
  • it could be a class, or module…
  • you want to test each unit in isolation, without involving other parts of the system
  • unit tests are implemented in code, usually with some framework

Basic Anatomy of a Test

Arrange, Act, Assert

  • Arrange: Set up the data, inputs, and environment required for the test

  • Act: Execute the function or code being tested

  • Assert: Check that the outcome matches expectations

I start by writing all my tests with these three comments, and fill in as I go along

Simple example (1)

R

R/multiply.R
multiply <- function(x, y) {
  x * y
}

python

src/multiply.py
def multiply(x, y):
  return x * y



This is assuming that in both cases your code is arranged as a package. E.g.

  • you have an R/ folder and DESCRIPTION/NAMESPACE files
  • a pyproject.toml and a src/ folder.

Simple example (2)

R

tests/testthat/test_multiply.R
library(testthat)

test_that(
  "it multiplies correctly",
  {
    # arrange
    x <- 3
    y <- 2

    # act
    actual <- multiply(x, y)
    
    # assert
    expect_equal(actual, 6)
  }
)

python

tests/test_multiply.py
from multiply import multiply

def test_multiplies_correctly():
  """test it multiplies correctly
  """
  # arrange
  x = 3
  y = 2

  # act
  actual = multiply(x, y)

  # assert
  assert actual == 6

Simple example (3)

R

tests/testthat/test_multiply.R
library(testthat)

test_that(
  "it multiplies correctly",
  {
    # act
    actual <- multiply(3, 2)
    
    # assert
    expect_equal(actual, 6)
  }
)

python

tests/test_multiply.py
from multiply import multiply

def test_multiplies_correctly():
  """test it multiplies correctly
  """
  # act
  actual = multiply(3, 2)

  # assert
  assert actual == 6

Simple example (4)

R

library(testthat)

# run all tests in a package
test_package(".")

# or, run a specific test file
test_file(
 "tests/testthat/test_multiply.R"
)

python

# from the command line

# run all tests
python -m pytest tests

# or, run a specific test file
pytest tests/test_multiply.py

# you may need to use python -m




IDEs (e.g. RStudio, Positron, VSCode) will have a way to run tests for you.

Running tests in VSCode

Other tips for structuring your tests

  • clean up: ensure that nothing changes between tests. Use fixtures (pytest [1], {testthat} [2]), or {withr} [3]
  • create a test per if/else branch: any time you have branched logic, write a separate test for each branch
  • don’t overcomplicate tests: each test should contain a single act step. Additional act = additional test
  • parameterize tests: to re-use testing logic, but different inputs (pytest.mark.parameterize [4], {patrick} [5])

How to structure your code


(to be easier to test)

Complex functions are hard to test

Consider this example

  • we are connecting to a database
  • we are getting data from that database
  • we add a new column to the table
  • we then filter out certain rows
  • finally we create and return a plot of the data
my_function <- function() {
  con <- dbConnect(odbc(), "dsn")

  df <- tbl(con, "my_table") |>
    mutate(value = x / y) |>
    filter(value > 0)

  ggplot(df, aes(y, value)) +
    geom_point() +
    geom_line()
}



There is a lot going on here - testing that each of these parts are working correctly, along with potential edge cases will be tricky!

Break complex functions up

get_data <- function() {
  con <- dbConnect(odbc(), "dsn")
  tbl(con, "my_table")
}

mutate_data <- function(df) {
  mutate(df, value = x / y)
}

filter_data <- function(df) {
  filter(df, value > 0)
}

plot_data <- function(df) {
  ggplot(df, aes(y, value)) +
    geom_point() + geom_line()
}
my_function <- function() {
  get_data() |>
    mutate_data() |>
    filter_data() |>
    plot_data()
}

Why is this easier to test?

  • our mutate and filter functions now can be tested without needing to connect to the database
  • they are pure functions [6], every time we run these we will get the same results back
  • they can be tested with simple example data frames, rather than the contents of the actual tables (which could have many columns or rows)

Why is this easier to test?

mutate_data

test_that("mutate_data behaves correctly", {
  # arrange
  df <- data.frame(x = c(1, 2, 3), y = c(4, 5, 6))
  expected <- data.frame(
    x = c(1, 2, 3),
    y = c(4, 5, 6),
    value = c(0.25, 0.4, 0.5)
  )

  # act
  actual <- mutate_data(df)

  # assert
  expect_equal(actual, expected)
})

We can use much simpler dataframe than might be expected in the real use of these functions.

But, by using simpler dataframes we can ensure that the only changes are the ones we are expecting.

In this case, a new column value is added.

Why is this easier to test?

filter_data

test_that("filter_data behaves correctly", {
  # arrange
  df <- data.frame(value = c(-1, 0, 1, 2))
  expected <- data.frame(value = c(1, 2))

  # act
  actual <- filter_data(df)

  # assert
  expect_equal(nrow(actual), 2)
  expect_equal(actual, expected)
})

We can use much simpler dataframe than might be expected in the real use of these functions.

But, by using simpler dataframes we can ensure that the only changes are the ones we are expecting.

In this case, we are expecting less rows of data, but the same structure of columns.

But what about the other functions?

  • testing mutate_data and filter_data was easy
  • but how about testing get_data which needs access to the database?
  • or plot_data, how can we test a plot?
  • or my_function, which calls all of the other functions?

Mocking

Mocking

In a unit test, mock objects can simulate the behavior of complex, real objects and are therefore useful when a real object is impractical or impossible to incorporate into a unit test. [7]

Mocking Example

R (function)

#' @importFrom odbc odbc
#' @importFrom dplyr tbl
#' @importFrom DBI dbConnect
get_data <- function() {
  con <- dbConnect(
    odbc(),
    "dsn"
  )

  tbl(con, "my_table")
}

Note: {testthat} recommends importing functions into your packages namespace if you want to mock the functions.

Mocking Example

R (arrange)

library(mockery)
test_that("it creates a valid database connection", {
  # arrange
  m_odbc <- Mock("odbc")
  m_dbConnect <- Mock("connection")
  m_tbl <- Mock("table")

  local_mocked_bindings(
    odbc = m_odbc,
    dbConnect = m_dbConnect,
    tbl = m_tbl
  )
  ...
})

Note: we create a “Mock” object for each of the functions we want to mock.

These Mock’s will simply return the values we pass in.

When the function is called, the mock will capture the call and value of arguments it was called with.

Mocking Example

R (act/assert)

library(mockery)
test_that("it creates a valid database connection", {
  ...

  # act
  actual <- get_data()

  # assert
  expect_equal(actual, "table")
  expect_called(m_odbc, 1) # repeat for other mocks
  
  expect_args(m_dbConnect, 1, "odbc", "dsn")
  expect_args(m_tbl, 1, "connection", "my_table")
})

Note: we can now validate that our functions (mocks) have been called the correct amount of times, and that they have been called with the correct arguments.

Mocking Example

python (function)

import pandas as pd
from sqlalchemy import create_engine

def get_data():
    engine = create_engine(
      "mssql+pyodbc://my_dsn"
    )
    
    return pd.read_sql_table("my_table", engine)

Note: assume that this is saved in a file called get_data.py, so the function to import would be get_data.get_data.

Mocking Example

python (arrange)

from get_data import get_data

def test_get_data(mocker):
    # arrange
    m_create_engine = mocker.patch(
      "get_data.create_engine",
      return_value="engine"
    )
    
    m_read_sql_table = mocker.patch(
      "pandas.read_sql_table",
      return_value="table"
    )
    
    ...

Note: the difference between mocking functions which are imported vs functions in modules which are imported.

This requires the pytest-mock plugin to be installed (via pip).

Mocking Example

python (act/assert)

from get_data import get_data

def test_get_data(mocker):
    ...
    # act
    actual = get_data()

    # assert
    assert actual == "table"

    m_create_engine.assert_called_once_with(
      "mssql+pyodbc://my_dsn"
    )
    
    m_read_sql_table.assert_called_once_with(
      "my_table", "engine"
    )

Note: the difference between mocking functions which are imported vs functions in modules which are imported.

This requires the pytest-mock plugin to be installed (via pip).

Using mocks with my_function

R function

my_function <- function() {
  get_data() |>
    mutate_data() |>
    filter_data() |>
    plot_data()
}

Using mocks with my_function

unit test (arrange)

test_that("it calls other functions correctly", {   
  # arrange
  m_get_data <- Mock("get_data")
  m_mutate_data <- Mock("mutate_data")
  m_filter_data <- Mock("filter_data")
  m_plot_data <- Mock("plot_data")

  local_mocked_bindings(
    get_data = m_get_data,
    mutate_data = m_mutate_data,
    filter_data = m_filter_data,
    plot_data = m_plot_data
  )

  # ...
})

Using mocks with my_function

unit test (arrange)

test_that("it calls other functions correctly", {
  # ...
  # act
  actual <- my_function()

  # assert
  expect_equal(actual, "plot_data")

  expect_called(m_get_data, 1)
  expect_args(m_get_data, 1)

  expect_args(m_mutate_data, 1, "get_data")
  expect_args(m_filter_data, 1, "mutate_data")

  expect_args(m_plot_data, 1, "filter_data")
})

::::

:::

Using mocks with my_function

integration test

test_that("fn calls all the other functions", {
  # arrange
  df <- data.frame(x = c(0, 1, 2), y = c(3, 4, 5))
  expected_df <- data.frame(x = c(1, 2), y = c(4, 5), value = c(0.25, 0.4))

  m_get_data <- Mock(df)
  m_plot_data <- Mock("plot_data")

  local_mocked_bindings(get_data = m_get_data, plot_data = m_plot_data)

  # act
  actual <- my_function()

  # assert
  expect_equal(actual, "plot_data")
  expect_args(m_plot_data, 1, expected_df)
})

But, what about the plot function?

  • Somethings are just difficult to write tests for
  • Plots for example: how can we write in code that the plot is correct?
  • We could just mock all of the function calls and state that is the correct behaviour…
  • Or, we could write a snapshot test

Snapshot testing

Snapshot testing

R

test_that(
  "it creates the plot correctly", {
    # arrange
    df <- create_sample_data_frame()

    # act
    actual <- plot_data(df)

    # assert
    expect_snapshot(actual)
  }
)

The first time we run this, it will create a snapshot of the plot. This will be a file saved to disk.

Next time we run the test, it will compare the before/after to see if the output of the function has changed.

If the snapshot ever changes, you can run snapshot_accept() to use the new snapshot.

Snapshot testing

python

# with the pytest-snapshot plugin

def test_plot_data(snapshot):
  # arrange
  df = create_sample_data_frame()

  # act
  actual = plot_data(df)

  # assert
  snapshot.assert_match(actual, "plot.png")

Similar to R, but you need to install the pytest-snapshot plugin first.

Then, you need to run pytest --snapshot-update to generate the initial snapshot, and run that same command any time you want to update the snapshot.

Where next?

Other tips

  • pure functions are much simpler to test, and to mock. Try to write pure functions wherever possible
  • avoid hardcoded values - pass in as arguments, or use configuration files
  • avoid complicated if/else statements. If unavoidable, make the body of the if/else functions
  • parameterize tests where possible, so you can re-use your test logic but test different branches of code
  • use code coverage to find areas of your code that aren’t tested, or aren’t used

References

[1]
pytest, About fixtures.”
[2]
testthat, Test fixtures.”
[3]
[4]
[5]
google, Patrick.”
[6]
[7]
Wikipedia, Mock object.”