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
11 changes: 11 additions & 0 deletions .github/workflows/spellcheck.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
name: spellcheck

on:
push:

jobs:
vale:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: simbo/vale-action@v1
14 changes: 14 additions & 0 deletions .vale.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
StylesPath = vale
Vocab = projectvocab

[formats]
qmd = md

[index.qmd]
BasedOnStyles = Vale

[pages/**/*.qmd]
BasedOnStyles = Vale

[README.md]
BasedOnStyles = Vale
21 changes: 21 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

This file is for contributors. It describes how the `stars-testing-intro` site is set-up and quality controlled.

## Environment

```
mamba env create -f environment.yaml
mamba activate stars-testing-intro
```

## Linting

Simply run from terminal:
Expand All @@ -10,6 +17,20 @@ Simply run from terminal:
bash lint.sh
```

## Spellcheck

There is a spellcheck GitHub action.

If you want to run the spellcheck locally, you need to install vale. On linux:

```
sudo apt update
sudo apt install snapd
sudo snap install vale
vale sync
vale .
```

## Example code

The example code is contained in `examples`.
Expand Down
24 changes: 12 additions & 12 deletions pages/defensive_programming.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ As a reminder this was our original code for the `calculate_wait_times()` functi

**Suggested changes:**

* Raise an **error** if the `df` parameter passed to the function is **not of type dataframe**.
* Raise an **error** if the `df` parameter passed to the function is **not of type DataFrame**.

* Raise an **error** if the `df` parameter **does not have one or more of the expected columns** within it.

Expand All @@ -71,7 +71,7 @@ These checks use a **hard stop** approach. They prevent the function from runnin

**Suggested changes:**

* If `df` is the edge case that does not contain any data (rows) **warn the user** and **return an empty dataframe** instead of attempting to perform a calculation (return early).
* If `df` is the edge case that does not contain any data (rows) **warn the user** and **return an empty DataFrame** instead of attempting to perform a calculation (return early).

:::

Expand All @@ -95,9 +95,9 @@ These extra steps help future users (including you in the future) to understand

::: {.python-content}

The first two issues are dealt with through structured exception handling. A `TypeError` is raised if anything other than a dataframe is passed to the function. A `ValueError` is raised if the columns (values) of the dataframe are not as expected.
The first two issues are dealt with through structured exception handling. A `TypeError` is raised if anything other than a DataFrame is passed to the function. A `ValueError` is raised if the columns (values) of the DataFrame are not as expected.

We handle the edge case of an empty dataframe through the combination of an `if` statement, the python `warnings` module, and an early return.
We handle the edge case of an empty DataFrame through the combination of an `if` statement, the python `warnings` module, and an early return.

You can hover over the code to see the changes highlighted in **bold**.

Expand Down Expand Up @@ -137,7 +137,7 @@ def calculate_wait_times(df):
UserWarning#<<
If the input DataFrame is empty.#<<
"""
# Check `df` is a dataframe. Hard fail using exception.#<<
# Check `df` is a DataFrame. Hard fail using exception.#<<
if not isinstance(df, pd.DataFrame):#<<
raise TypeError(f"Expected pandas DataFrame, got {type(df).__name__}")#<<

Expand All @@ -148,7 +148,7 @@ def calculate_wait_times(df):
if missing_cols:#<<
raise ValueError(f"Missing required columns: {missing_cols}")#<<

# Check if dataframe is empty. Graceful fail return early + raise warning#<<
# Check if DataFrame is empty. Graceful fail return early + raise warning#<<
if df.empty:#<<
df = df.copy()#<<
warnings.warn(#<<
Expand Down Expand Up @@ -185,7 +185,7 @@ def calculate_wait_times(df):

### Passing the wrong data type

Hypothetically let's take a scenario where the user's preprocessing of the data does not match what the function expects. Here the user has the data held in a Python list as an intermediate processing step and passes it to the function before converting to a dataframe. The code terminates immediately and we get a clear error message.
Hypothetically let's take a scenario where the user's preprocessing of the data does not match what the function expects. Here the user has the data held in a Python list as an intermediate processing step and passes it to the function before converting to a DataFrame. The code terminates immediately and we get a clear error message.

```{python}
#| error: true
Expand Down Expand Up @@ -214,7 +214,7 @@ result = calculate_wait_times(incomplete_df)

### The edge case

When the dataframe is empty a warning is raised, an empty dataframe is returned and the code continues to run:
When the DataFrame is empty a warning is raised, an empty DataFrame is returned and the code continues to run:

```{python}
#| warning: true
Expand All @@ -233,9 +233,9 @@ result = calculate_wait_times(empty_df)

::: {.r-content}

The first two issues are dealt with using the R `stop()` function. This is called if anything other than a dataframe is passed to the function or raised if the columns (values) of the dataframe are not as expected.
The first two issues are dealt with using the R `stop()` function. This is called if anything other than a DataFrame is passed to the function or raised if the columns (values) of the DataFrame are not as expected.

We handle the edge case of an empty dataframe through the combination of an `if` statement, the `warning()` function, and an early return.
We handle the edge case of an empty DataFrame through the combination of an `if` statement, the `warning()` function, and an early return.

```{r}
#' Add arrival/service datetimes and waiting time in minutes.
Expand Down Expand Up @@ -360,7 +360,7 @@ result <- calculate_wait_times(incomplete_df)

### The edge case

When the dataframe is empty a warning is raised, an empty dataframe is returned and the code continues to run:
When the DataFrame is empty a warning is raised, an empty DataFrame is returned and the code continues to run:

```{r}
#| warning: true
Expand All @@ -385,7 +385,7 @@ Here are some examples to test the defensive additions to `calculate_wait_times(

* Test that passing a different data structure than a DataFrame raises the appropriate error and message.
* Test that passing a DataFrame missing one or more required columns raises the appropriate error and message.
* Test that an empty DataFrame returns the correct structure with arrival_datetime, service_datetime, and waittime columns
* Test that an empty DataFrame returns the correct structure with `arrival_datetime`, `service_datetime`, and `waittime` columns
* If implemented, test that an empty DataFrame with valid column triggers a warning.


Expand Down
12 changes: 6 additions & 6 deletions pages/github_actions.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ Finally, we run the tests! We call `pytest` on our case study (`examples/python_

:::: {.r-content}

There are lots of ways to set up a testing action, depending on how your research is structured, and whether you are using an renv, and so on.
There are lots of ways to set up a testing action, depending on how your research is structured, and whether you are using an `renv`, and so on.

If your work is structured as a package, you can call:

Expand All @@ -162,7 +162,7 @@ Which action do you want to add? (0 to exit)
Selection:
```

However, for this tutorial, we will show an example where dependencies are restored from an renv and the tests are run via `devtools::test()`, running on ubuntu and specific version of R.
However, for this tutorial, we will show an example where dependencies are restored from an `renv` and the tests are run via `devtools::test()`, running on Ubuntu and specific version of R.

However, you can do multiple operating systems! (Just see Python tutorial for how!)

Expand Down Expand Up @@ -204,7 +204,7 @@ jobs:
RENV_CONFIG_PAK_ENABLED: true
```

Now we define the `tests` job. We specify an operating system (`ubuntu-latest`) and set `RENV_CONFIG_PAK_ENABLED` to true as it allows renv to use pak for faster, more reliable dependency installation where possible.
Now we define the `tests` job. We specify an operating system (`ubuntu-latest`) and set `RENV_CONFIG_PAK_ENABLED` to true as it allows `renv` to use `pak` for faster, more reliable dependency installation where possible.

```
steps:
Expand All @@ -225,7 +225,7 @@ The first step checks out the repository so the workflow has access to the code.
Rscript -e 'renv::restore(project = ".")'
```

This step installs and configures R on the runner. The r-version is pinned to 4.4.1 here, with a specific renv then restored - but you could also run on latest versions.
This step installs and configures R on the runner. The r-version is pinned to 4.4.1 here, with a specific `renv` then restored - but you could also run on latest versions.

```
- name: Run testthat tests
Expand All @@ -247,7 +247,7 @@ The video below demonstrates this workflow running in GitHub Actions. For the de

* Open the [GitHub repository](https://github.com/pythonhealthdatascience/stars-testing-intro).
* Go to the [Actions](https://github.com/pythonhealthdatascience/stars-testing-intro/actions) tab.
* Click on the [python_tests](https://github.com/pythonhealthdatascience/stars-testing-intro/actions/workflows/python_tests.yaml) action.
* Click on the [`python_tests`](https://github.com/pythonhealthdatascience/stars-testing-intro/actions/workflows/python_tests.yaml) action.
* View the [workflow YAML](https://github.com/pythonhealthdatascience/stars-testing-intro/blob/main/.github/workflows/python_tests.yaml).
* Trigger the action via `workflow_dispatch`.
* [View the running test](https://github.com/pythonhealthdatascience/stars-testing-intro/actions/runs/21067946032) - see they are installing the requirements, running tests, and pass.
Expand All @@ -264,7 +264,7 @@ The tests all pass in the end-

* Open the [GitHub repository](https://github.com/pythonhealthdatascience/stars-testing-intro).
* Go to the [Actions](https://github.com/pythonhealthdatascience/stars-testing-intro/actions) tab.
* Click on the [r_tests](https://github.com/pythonhealthdatascience/stars-testing-intro/actions/workflows/r_tests.yaml) action.
* Click on the [`r_tests`](https://github.com/pythonhealthdatascience/stars-testing-intro/actions/workflows/r_tests.yaml) action.
* View the [workflow YAML](https://github.com/pythonhealthdatascience/stars-testing-intro/blob/main/.github/workflows/r_tests.yaml).
* Trigger the action via `workflow_dispatch`, running it from the `dev` branch.
* [View the running test](https://github.com/pythonhealthdatascience/stars-testing-intro/actions/runs/21166564091) - see they are installing the packages, getting ready to run tests.
Expand Down
4 changes: 2 additions & 2 deletions pages/parametrising_tests.qmd
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
title: "Parameterising tests"
title: "Parametrising tests"
---

```{r}
Expand All @@ -14,7 +14,7 @@ use_condaenv("stars-testing-intro", required = TRUE)

There are many tools you can make use of when testing - one example is **parametrising** tests.

When you need to test the same logic with different inputs and expected outputs, you can parameterise your tests instead of writing repetitive test functions. This minimises code duplication and makes it easy to add new test cases.
When you need to test the same logic with different inputs and expected outputs, you can parametrise your tests instead of writing repetitive test functions. This minimises code duplication and makes it easy to add new test cases.

## Example: Testing `summary_stats()`

Expand Down
14 changes: 7 additions & 7 deletions pages/run_tests.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ from src.analysis import summary_stats

### 2. Test files following the `test_` naming convention

Pytest automatically discovers and runs tests when your test files start with `test_`. It is also typical (but not mandatory) to store test files in a folder called `tests/`. For example:
In pytest, tests are automatically discovered and run when your test files start with `test_`. It is also typical (but not mandatory) to store test files in a folder called `tests/`. For example:

```
tests/
Expand Down Expand Up @@ -152,7 +152,7 @@ testthat::test_dir("tests/testthat")

In the video below, we have an example of a research project structured as an our R project.

* We highlight that the renv is active (`Project '~/Documents/stars/stars-testing-intro' loaded. [renv 1.1.5]`).
* We highlight that the `renv` is active (`Project '~/Documents/stars/stars-testing-intro' loaded. [renv 1.1.5]`).
* We show the test file we will be running (`test_intro_simple.R`).
* We run `devtools::test()` and can see that all tests pass.

Expand Down Expand Up @@ -188,7 +188,7 @@ In the video below, we:

::: {.r-content}

You don't need a full package to use testthat. When your code exists in `.R` files but isn't packaged, you can still run tests from the R console. You project might, for example, look like:
You don't need a full package to use `testthat`. When your code exists in `.R` files but isn't packaged, you can still run tests from the R console. You project might, for example, look like:

```
project/
Expand All @@ -215,9 +215,9 @@ In the video below, we:

While running tests from the command line is the recommended approach, there are alternative tools for specific use cases.

### testbook
### `testbook`

[testbook](https://testbook.readthedocs.io/) allows you to test code within Jupyter notebooks:
[`testbook`](https://testbook.readthedocs.io/) allows you to test code within Jupyter notebooks:

```{.python}
# Install: pip install testbook
Expand All @@ -229,9 +229,9 @@ def test_notebook_function(tb):
assert func(2, 3) == 5
```

### ipytest
### `ipytest`

[ipytest](https://github.com/chmp/ipytest) lets you run pytest directly inside Jupyter notebook cells:
[`ipytest`](https://github.com/chmp/ipytest) lets you run pytest directly inside Jupyter notebook cells:

```{.python}
# Install: pip install ipytest
Expand Down
2 changes: 1 addition & 1 deletion pages/smoke_tests.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ In the example above, we treated the smoke test as a separate file (`test_smoke.

As your project grows, you might instead prefer to group tests by what they cover (for example `test_patient_flow.py`, `test_dispatch_logic.py`) rather than by type (smoke, unit, integration).

**Pytest markers** let you tag individual tests as "smoke" (or other test types) while keeping this feature‑based organisation. For example:
There are **pytest markers** let you tag individual tests as "smoke" (or other test types) while keeping this feature‑based organisation. For example:

```{.python}
import pytest
Expand Down
10 changes: 5 additions & 5 deletions pages/temp_mock.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -209,9 +209,9 @@ The trade‑off is that mocking is more complex to understand and set up than us

### Example

Pytest provides the `monkeypatch` fixture for mocking. It allows us to temporarily modify functions or attributes during a test. Here, we replace `pd.read_csv` with a mock function that returns a pre-defined DataFrame.
`pytest` provides the `monkeypatch` fixture for mocking. It allows us to temporarily modify functions or attributes during a test. Here, we replace `pd.read_csv` with a mock function that returns a pre-defined DataFrame.

Because `pd.read_csv` is patched, the file path passed to `import_patient_data` is irrelvant - no file is ever opened. The file behaves as if it is successfully read a CSV file, allowing us to focus purely on testing the data processing logic.
Because `pd.read_csv` is patched, the file path passed to `import_patient_data` is irrelevant - no file is ever opened. The file behaves as if it is successfully read a CSV file, allowing us to focus purely on testing the data processing logic.

```{python}
#| eval: false
Expand Down Expand Up @@ -312,7 +312,7 @@ In Python, we often mix all three patterns:

* **Temporary file** - a good default for many tests where you just need a small, representative dataset with the right structure. For example, this works well in [smoke tests](smoke_tests.qmd) and [system tests](system_tests.qmd).

* **Mocking** or **alternative design** - most useful when your code lives in a package and you want fast, isolated [unit tests](unit_tests.qmd) where you want to **isolate your own logic from external libraries and the filesystem**, and avoid file I/O entirely. However, not every unit test requries this: sometimes you deliberately call the real library function because you want to confirm you are using it correctly (for example, that you pass the right arguments, or that it can handle your expected input format).
* **Mocking** or **alternative design** - most useful when your code lives in a package and you want fast, isolated [unit tests](unit_tests.qmd) where you want to **isolate your own logic from external libraries and the filesystem**, and avoid file I/O entirely. However, not every unit test requires this: sometimes you deliberately call the real library function because you want to confirm you are using it correctly (for example, that you pass the right arguments, or that it can handle your expected input format).

:::

Expand Down Expand Up @@ -355,7 +355,7 @@ In R, the modern recommended tool for mocking is `testthat::local_mocked_binding

Mocking temporarily changes that mapping for a given function name, so during the test the package calls your fake version instead of the original one, and then everything is restored afterwards.

The [testthat documentation](https://rdrr.io/cran/testthat/man/local_mocked_bindings.html) describes four places the function you want to mock might come from:
The [`testthat` documentation](https://rdrr.io/cran/testthat/man/local_mocked_bindings.html) describes four places the function you want to mock might come from:

* A function defined inside your package.
* A function you imported from another package via `NAMESPACE` (using `@import` or `@importFrom`).
Expand Down Expand Up @@ -393,7 +393,7 @@ test_that("providing data to a test via mocking", {

Here, `local_mocked_bindings()` temporarily replaces the `read_csv()` binding inside the `waitingtimes` namespace, so any call to `read_csv()` from our package code uses the fake version during the test

The fourth case (`pkg::fun`) is trickier. If your code calls `readr::read_csv()` directly, then the function you want to mock lives in the `readr` namespace, not your own. You *can* point `local_mocked_bindings()` at another package by setting `.package = "readr"`, but the [testthat documentation](https://rdrr.io/cran/testthat/man/local_mocked_bindings.html) cautions against this, because it will affect all calls to `readr::read_csv()`.
The fourth case (`pkg::fun`) is trickier. If your code calls `readr::read_csv()` directly, then the function you want to mock lives in the `readr` namespace, not your own. You *can* point `local_mocked_bindings()` at another package by setting `.package = "readr"`, but the [`testthat` documentation](https://rdrr.io/cran/testthat/man/local_mocked_bindings.html) cautions against this, because it will affect all calls to `readr::read_csv()`.

The [documentation](https://rdrr.io/cran/testthat/man/local_mocked_bindings.html) instead recommends a safer pattern:

Expand Down
4 changes: 2 additions & 2 deletions pages/test_coverage.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ use_condaenv("stars-testing-intro", required = TRUE)

## `pytest-cov`

The [pytest-cov](https://github.com/pytest-dev/pytest-cov) package can be used to run coverage calculations easily alongside `pytest`. You can install it from PyPI or conda:
The [pytest-cov](https://github.com/pytest-dev/pytest-cov) package can be used to run coverage calculations easily alongside `pytest`. You can install it from PyPI or conda-forge:

```{.bash}
pip install pytest-cov
Expand Down Expand Up @@ -70,7 +70,7 @@ If your research is structured as a package, then you can use `devtools` to calc
devtools::test_coverage()
```

If not structured as a package, you can use [covr](https://github.com/r-lib/covr)'s `file_coverage()` function - for example:
If not structured as a package, you can use [`covr`](https://github.com/r-lib/covr)'s `file_coverage()` function - for example:

```{.r}
covr::file_coverage(
Expand Down
Loading
Loading