Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
192 changes: 134 additions & 58 deletions presentations/2025-11-14_unit-testing/index.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@ In a current code base, I have ~2,000 lines of code (ignoring comments), and ~4,
- How to structure your code to be easier to test
- How to use mocks to replace complex dependencies

# What is a [unit]{.yellow} anyway? {.inverse}
# What is a [unit]{.blue} anyway? {.inverse}

## What is a [unit]{.yellow} anyway?
## What is a [unit]{.blue} anyway?

:::{.incremental}
* a unit is the [**smallest**]{.blue} testable part of a program
Expand Down Expand Up @@ -57,7 +57,7 @@ examples of testing frameworks:
</ul>
:::

# Basic Anatomy of a Test {.inverse}
# Basic [Anatomy]{.yellow} of a [Test]{.blue} {.inverse}

## Arrange, Act, Assert

Expand Down Expand Up @@ -249,7 +249,7 @@ pytest tests/test_multiply.py

# you may need to use python -m
```

::::

:::
Expand All @@ -259,12 +259,18 @@ pytest tests/test_multiply.py
<br />
[IDEs (e.g. RStudio, Positron, VSCode) will have a way to run tests for you.]{.light-charcoal .small .fragment}

## Running tests in VSCode {.inverse}

![](vscode-test-runner.png)

## Other tips for structuring your tests

:::{.incremental}
- [**clean up**:]{.blue} ensure that nothing changes between tests. Use fixtures (pytest [@pytest_fixtures], {testthat} [@testthat_fixtures]), or {withr} [@withr]
- [**don't overcomplicate tests**:]{.blue} each test should contain a single act step. Additional act = additional test
- [**create a test per if/else branch**:]{.blue} any time you have branched logic, write a separate test for each branch
- [**don't overcomplicate tests**:]{.blue} each test should contain a single act step. Additional act = additional test
- [**parameterize tests**:]{.blue} to re-use testing logic, but different inputs (pytest.mark.parameterize [@pytest_parametrize], {patrick} [@patrick_package])
:::

# How to [structure]{.yellow} your [code]{.blue} {.inverse}

Expand Down Expand Up @@ -380,9 +386,7 @@ At least, some of these units are easier to test. We will come back to the get_d

## Why is this easier to test? {.code-page}

:::{.panel-tabset}

### mutate_data
### mutate_data {.blue}

:::{.columns}

Expand Down Expand Up @@ -422,7 +426,9 @@ In this case, a new column `value` is added.

:::

### filter_data
## Why is this easier to test? {.code-page}

### filter_data {.blue}

:::{.columns}

Expand Down Expand Up @@ -460,9 +466,6 @@ In this case, we are expecting less rows of data, but the same structure of colu

:::

:::


## But what about the other functions?

:::{.incremental}
Expand All @@ -472,17 +475,15 @@ In this case, we are expecting less rows of data, but the same structure of colu
- or `my_function`, which calls all of the other functions?
:::

# Mocking {.inverse}
# [Mocking]{.blue} {.inverse}

## 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. [@mocking]

## Mocking Example [R]{.blue} {.code-page}
## Mocking Example {.code-page}

:::{.panel-tabset}

### R function
### [R (function)]{.blue}

:::{.columns}

Expand Down Expand Up @@ -515,7 +516,9 @@ want to mock the functions.

:::

### R test (arrange)
## Mocking Example {.code-page}

### [R (arrange)]{.blue}

:::{.columns}

Expand Down Expand Up @@ -554,7 +557,9 @@ When the function is called, the mock will capture the call and value of argumen

:::

### R test (act)
## Mocking Example {.code-page}

### [R (act/assert)]{.blue}

:::{.columns}

Expand Down Expand Up @@ -590,13 +595,9 @@ that they have been called with the correct arguments.

:::

:::

## Mocking Example [python]{.yellow} {.code-page}

:::{.panel-tabset}
## Mocking Example {.code-page}

### python function
### [python (function)]{.yellow}

:::{.columns}

Expand Down Expand Up @@ -626,28 +627,72 @@ def get_data():

:::

### python test
## Mocking Example {.code-page}

### [python (arrange)]{.yellow}

::::{.columns}

::::{.column width=75%}

``` python
from get_data import get_data
# using the pytest-mock plugin

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

m_read_sql_table = mocker.patch(
"pandas.read_sql_table", return_value="table")
"pandas.read_sql_table",
return_value="table"
)

...
```

::::

::::{.column width=25%}

:::{.small .light-charcoal}
[**Note**:]{.yellow} 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 {.code-page}

### [python (act/assert)]{.yellow}

::::{.columns}

::::{.column width=75%}

``` python
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")
"mssql+pyodbc://my_dsn"
)

m_read_sql_table.assert_called_once_with(
"my_table", "engine")
"my_table", "engine"
)
```

::::
Expand All @@ -656,19 +701,17 @@ def test_get_data(mocker):

:::{.small .light-charcoal}
[**Note**:]{.yellow} 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` {.code-page}

:::{.panel-tabset}

### R function
### R function {.yellow}

``` r
my_function <- function() {
Expand All @@ -679,48 +722,79 @@ my_function <- function() {
}
```

### unit test
## Using mocks with `my_function` {.code-page}

### unit test (arrange) {.yellow}

``` r
test_that("it calls all the other functions", {
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 <- Mock("get_data")),
mutate_data = (m_mutate_data <- Mock("mutate_data")),
filter_data = (m_filter_data <- Mock("filter_data")),
plot_data = (m_plot_data <- Mock("plot_data"))
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` {.code-page}

### unit test (arrange) {.yellow}

``` r
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` {.code-page}

### integration test
### integration test {.yellow}

``` r
test_that("fn calls all the other functions", {
local_mocked_bindings(
get_data = (m_get_data <- data.frame(
x = c(0, 1, 2), y = c(3, 4, 5))),
plot_data = (m_plot_data <- Mock("plot_data"))
)
# 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)

actual <- fn()
# act
actual <- my_function()

# assert
expect_equal(actual, "plot_data")
expect_args(m_plot_data, 1, data.frame(
x = c(1, 2), y = c(4, 5), value = c(0.25, 0.4))
expect_args(m_plot_data, 1, expected_df)
})
```

:::

## But, what about the plot function?

:::{.incremental}
Expand All @@ -732,11 +806,11 @@ test_that("fn calls all the other functions", {

:::

## Snapshot testing {.code-page}
# [Snapshot]{.blue} testing {.inverse}

:::{.panel-tabset}
## Snapshot testing {.code-page}

## R
### R {.blue}

:::{.columns}

Expand Down Expand Up @@ -773,7 +847,9 @@ If the snapshot ever changes, you can run `snapshot_accept()` to use the new sna

:::

## python
## Snapshot testing {.code-page}

### python {.yellow}

:::{.columns}

Expand Down Expand Up @@ -807,7 +883,7 @@ Then, you need to run `pytest --snapshot-update` to generate the initial snapsho

:::

:::
# Where next? {.inverse}

## Other tips

Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading