diff --git a/.github/workflows/spellcheck.yaml b/.github/workflows/spellcheck.yaml new file mode 100644 index 0000000..e7c789b --- /dev/null +++ b/.github/workflows/spellcheck.yaml @@ -0,0 +1,11 @@ +name: spellcheck + +on: + push: + +jobs: + vale: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: simbo/vale-action@v1 \ No newline at end of file diff --git a/.vale.ini b/.vale.ini new file mode 100644 index 0000000..1e0082d --- /dev/null +++ b/.vale.ini @@ -0,0 +1,14 @@ +StylesPath = vale +Vocab = projectvocab + +[formats] +qmd = md + +[index.qmd] +BasedOnStyles = Vale + +[pages/**/*.qmd] +BasedOnStyles = Vale + +[README.md] +BasedOnStyles = Vale \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8c37e18..b41a91b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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: @@ -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`. diff --git a/pages/defensive_programming.qmd b/pages/defensive_programming.qmd index 80aacb9..f6cf22b 100644 --- a/pages/defensive_programming.qmd +++ b/pages/defensive_programming.qmd @@ -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. @@ -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). ::: @@ -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**. @@ -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__}")#<< @@ -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(#<< @@ -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 @@ -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 @@ -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. @@ -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 @@ -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. diff --git a/pages/github_actions.qmd b/pages/github_actions.qmd index fa436f2..8c5c462 100644 --- a/pages/github_actions.qmd +++ b/pages/github_actions.qmd @@ -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: @@ -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!) @@ -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: @@ -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 @@ -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. @@ -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. diff --git a/pages/parametrising_tests.qmd b/pages/parametrising_tests.qmd index 0c603aa..456cd05 100644 --- a/pages/parametrising_tests.qmd +++ b/pages/parametrising_tests.qmd @@ -1,5 +1,5 @@ --- -title: "Parameterising tests" +title: "Parametrising tests" --- ```{r} @@ -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()` diff --git a/pages/run_tests.qmd b/pages/run_tests.qmd index 2ac4869..2f1664e 100644 --- a/pages/run_tests.qmd +++ b/pages/run_tests.qmd @@ -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/ @@ -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. @@ -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/ @@ -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 @@ -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 diff --git a/pages/smoke_tests.qmd b/pages/smoke_tests.qmd index 5198061..ef722fd 100644 --- a/pages/smoke_tests.qmd +++ b/pages/smoke_tests.qmd @@ -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 diff --git a/pages/temp_mock.qmd b/pages/temp_mock.qmd index 3e84c73..73a96b3 100644 --- a/pages/temp_mock.qmd +++ b/pages/temp_mock.qmd @@ -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 @@ -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). ::: @@ -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`). @@ -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: diff --git a/pages/test_coverage.qmd b/pages/test_coverage.qmd index b83c0ad..98db974 100644 --- a/pages/test_coverage.qmd +++ b/pages/test_coverage.qmd @@ -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 @@ -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( diff --git a/pages/unit_tests.qmd b/pages/unit_tests.qmd index 7bf90a5..65ec906 100644 --- a/pages/unit_tests.qmd +++ b/pages/unit_tests.qmd @@ -40,10 +40,10 @@ Let's use the `import_patient_data()` function from our case study. Its main behaviours are that it: -1. Reads a CSV into a dataframe. +1. Reads a CSV into a DataFrame. 2. Requires the columns to match a specific list exactly (names and order). 3. Stops if columns are incorrect. -4. Returns a dataframe with raw patient-level data. +4. Returns a DataFrame with raw patient-level data. ::: @@ -89,7 +89,7 @@ Your docstring makes **promises** about how the function should behave. For `imp ::: {.r-content} -* Return a dataframe. +* Return a DataFrame. * Stop if columns are incorrect. ::: @@ -111,7 +111,7 @@ In our case, we can create a small table with correct columns in the right order ::: {.r-content} -* The result is a dataframe. +* The result is a DataFrame. * The columns match exactly. ::: @@ -171,7 +171,7 @@ For `import_patient_data()`, the function should stop with an error if we have: * Extra columns * Correct columns but wrong order -For each case, we can create a small dataframe with the problem and check that the function fails. +For each case, we can create a small DataFrame with the problem and check that the function fails. ```{r} #| eval: false diff --git a/pages/write_basic_test.qmd b/pages/write_basic_test.qmd index 30574ee..81a69e1 100644 --- a/pages/write_basic_test.qmd +++ b/pages/write_basic_test.qmd @@ -18,9 +18,9 @@ On this page, we explain how to write a basic automated test in Python using **p ## What is pytest? -Pytest is a popular framework for testing in Python, widely used in software development and data science. +`pytest` is a popular framework for testing in Python, widely used in software development and data science. -You should install the `pytest` package into your environment from either conda or PyPI: +You should install the `pytest` package into your environment from either conda-forge or PyPI: ```{.bash} conda install pytest @@ -48,11 +48,11 @@ def test_example(): ::: {.r-content} -On this page, we explain how to write a basic automated test in R using **testthat**. +On this page, we explain how to write a basic automated test in R using **`testthat`**. -## testthat +## `testthat` -testthat is a popular framework for testing in R, widely used in software development and data science. +`testthat` is a popular framework for testing in R, widely used in software development and data science. You should install the `testthat` package into your environment from CRAN. @@ -77,9 +77,9 @@ usethis::use_test("filename") This creates a new file called `tests/testthat/test-filename.R`, and opens it in your editor so you can start adding tests. We explain how this folder structure is used when you [run tests on the next page](run_tests.qmd). -## What a testthat test looks like +## What a `testthat` test looks like -Tests are created using `test_that()`. They are built around expectations like `expect_true()`, `expect_false()`, `expect_equal()`, `expect_error()`, and others (see [package index](https://testthat.r-lib.org/reference/index.html) for more). If an expectation fails, testthat will return an error message explaining what went wrong. +Tests are created using `test_that()`. They are built around expectations like `expect_true()`, `expect_false()`, `expect_equal()`, `expect_error()`, and others (see [package index](https://testthat.r-lib.org/reference/index.html) for more). If an expectation fails, `testthat` will return an error message explaining what went wrong. For example: diff --git a/vale/config/vocabularies/projectvocab/accept.txt b/vale/config/vocabularies/projectvocab/accept.txt new file mode 100644 index 0000000..8ac65cf --- /dev/null +++ b/vale/config/vocabularies/projectvocab/accept.txt @@ -0,0 +1,31 @@ +APIs +Arina +Boukari +datetime +datetimes +docstring +docstrings +downloadthis +experiencesdashboard +Fong +Hulme +Kunle +namespace +nhp_model +NHSRwaitinglist +Ojedele +Oreagba +opencodecounts +[Pp]arametrising +pydesrap_mms +pydesrap_stroke +pytest +pytest's +rdesrap_mms +rdesrap_stroke +Schaffer +Tamborska +Viveck +Wiedemann +Yamina +[Zz]enodo \ No newline at end of file