Validation and testing

Lesson 8 — Clinical Programming with R

Lesson 8 of 12 Advanced ~100 min

Learning objectives

  • Distinguish validation, verification and QC
  • Run an independent double-programming comparison with diffdf
  • Write unit tests for derivations and trace them to requirements
  • Assess whether an R package is fit for regulated use
  • Produce the evidence a QC or audit review expects

Three words that get confused

Term Question Who
Validation Does the system do what it is intended to do, in its intended environment? Quality function, system level
Verification Does the output match the specification? QC programmer, output level
Testing Does this code do what the programmer intended? Programmer, code level

Unit tests are testing. Independent double programming is verification. Neither, on its own, is validation — validation is a documented, system-level activity that includes both plus environment qualification, change control and review records.

Independent double programming

The industry’s primary verification method: two programmers implement the same specification independently and compare outputs.

Specification (SAP + ADaM spec)
        │
        ├──▶ Production programmer ──▶ adsl.rds
        │
        └──▶ QC programmer ──────────▶ adsl_qc.rds
                                            │
                                     compare ──▶ differences ──▶ resolve

“Independent” means the QC programmer works from the specification, not from the production code. Reading the production code first — even briefly — defeats the purpose, because misreadings of the specification propagate.

diffdf

library(diffdf)

prod <- readRDS("data/adam/adsl.rds")
qc   <- readRDS("data/adam_qc/adsl.rds")

diffdf(prod, qc, keys = "USUBJID")
Differences found between the objects!

Not all Values Compared Equal
  Variable  No of Differences
  TRTDURD                  3
  AGEGR1                  12

All rows are shown in table below

  ==========================================
   VARIABLE   USUBJID     BASE   COMPARE
  ------------------------------------------
   TRTDURD  01-701-1015    182       183
   TRTDURD  01-701-1023    170       171
   AGEGR1   01-703-1096   65-80      >80
  ==========================================

Options:

diffdf(
  prod, qc,
  keys        = c("USUBJID", "PARAMCD", "AVISITN"),
  suppress_warnings = FALSE,
  tolerance   = 1e-8,        # numeric comparison tolerance
  strict_numeric = FALSE,    # allow integer vs double
  strict_factor  = FALSE,    # allow factor vs character
  file        = "output/qc/diffdf_adsl.txt"
)

# Programmatic use
result <- diffdf(prod, qc, keys = "USUBJID")
diffdf_has_issues(result)
#> [1] TRUE
Importanttolerance is not a way to make differences disappear

Setting tolerance = 0.01 to silence a difference of 0.008 hides a real discrepancy. Numeric differences at the 1e-8 level are floating-point noise; differences at the 1e-3 level are two different calculations.

Investigate before adjusting the tolerance. The usual causes of genuine floating-point noise are different operation orders and different rounding points — both worth understanding rather than suppressing.

Interpreting differences

Difference Usual cause
Different row counts One programmer filtered differently, or a join fanned out
A variable in one and not the other Spec ambiguity about whether it is required
Systematic off-by-one in a day variable Day zero convention
Differences only for partial dates Imputation convention
Differences in the last decimal only Rounding convention (half-even vs half-up)
Differences for a handful of subjects Genuine edge case — this is the valuable one

Every difference must be resolved and documented, with a decision: production was wrong, QC was wrong, or the specification was ambiguous. The third outcome is common and is the most valuable result of the exercise — an ambiguous spec would have caused a problem eventually.

QC comparison at scale

compare_all <- function(prod_dir, qc_dir, keys_map) {
  datasets <- intersect(
    tools::file_path_sans_ext(list.files(prod_dir, "\\.rds$")),
    tools::file_path_sans_ext(list.files(qc_dir,   "\\.rds$"))
  )

  purrr::map(datasets, function(ds) {
    p <- readRDS(file.path(prod_dir, paste0(ds, ".rds")))
    q <- readRDS(file.path(qc_dir,   paste0(ds, ".rds")))

    res <- diffdf(p, q, keys = keys_map[[ds]],
                  file = file.path("output/qc", paste0("diffdf_", ds, ".txt")),
                  suppress_warnings = TRUE)

    tibble::tibble(
      dataset      = ds,
      prod_rows    = nrow(p),
      qc_rows      = nrow(q),
      prod_cols    = ncol(p),
      qc_cols      = ncol(q),
      has_issues   = diffdf_has_issues(res),
      status       = if (diffdf_has_issues(res)) "DIFFERENCES" else "MATCH"
    )
  }) |>
    purrr::list_rbind()
}

compare_all("data/adam", "data/adam_qc",
            keys_map = list(
              adsl = "USUBJID",
              adae = c("USUBJID", "AESEQ"),
              adlb = c("USUBJID", "PARAMCD", "AVISITN", "ADT")
            ))
#> # A tibble: 3 x 7
#>   dataset prod_rows qc_rows prod_cols qc_cols has_issues status
#>   <chr>       <int>   <int>     <int>   <int> <lgl>      <chr>
#> 1 adsl          306     306        58      58 FALSE      MATCH
#> 2 adae         1191    1191        45      45 TRUE       DIFFERENCES
#> 3 adlb        20983   20983        62      62 FALSE      MATCH

Comparing tables

For TLFs, compare the computed numbers, not the RTF:

prod_tbl <- readRDS("output/tables/t_14_1_1_data.rds")
qc_tbl   <- readRDS("output/qc/t_14_1_1_data.rds")

diffdf(prod_tbl, qc_tbl, keys = "label")

This is why lesson 6 recommends saving the numeric result alongside every table. Comparing RTF files byte by byte fails on timestamps and tells you nothing about which number differs.

Unit testing derivations

Double programming verifies one dataset on one day. Unit tests verify the logic, every time anything changes, in seconds.

# tests/testthat/test-derive-trtemfl.R

test_that("REQ-042: events on or after first dose are treatment emergent", {
  ae  <- make_ae(astdt = c("2026-03-15", "2026-04-01"))
  out <- derive_trtemfl(ae, trtsdt = as.Date("2026-03-15"),
                            trtedt = as.Date("2026-06-15"))
  expect_equal(out$TRTEMFL, c("Y", "Y"))
})

test_that("REQ-042: events before first dose are not treatment emergent", {
  ae  <- make_ae(astdt = c("2026-03-14", "2026-01-01"))
  out <- derive_trtemfl(ae, trtsdt = as.Date("2026-03-15"),
                            trtedt = as.Date("2026-06-15"))
  expect_equal(out$TRTEMFL, c("N", "N"))
})

test_that("REQ-043: the 30-day follow-up window is inclusive", {
  ae  <- make_ae(astdt = c("2026-07-15", "2026-07-16"))  # TRTEDT + 30, +31
  out <- derive_trtemfl(ae, trtsdt = as.Date("2026-03-15"),
                            trtedt = as.Date("2026-06-15"))
  expect_equal(out$TRTEMFL, c("Y", "N"))
})

test_that("REQ-044: subjects with no treatment start are flagged N", {
  ae  <- make_ae(astdt = "2026-04-01")
  out <- derive_trtemfl(ae, trtsdt = as.Date(NA), trtedt = as.Date(NA))
  expect_equal(out$TRTEMFL, "N")
})

Putting the requirement ID in the test name makes the traceability matrix a grep:

traceability <- function(test_dir = "tests/testthat") {
  files <- list.files(test_dir, pattern = "^test-", full.names = TRUE)

  purrr::map(files, function(f) {
    lines <- readLines(f)
    tests <- grep('^\\s*test_that\\(', lines, value = TRUE)

    tibble::tibble(
      file = basename(f),
      test = stringr::str_match(tests, 'test_that\\("(.*?)"')[, 2]
    )
  }) |>
    purrr::list_rbind() |>
    mutate(requirement = stringr::str_extract(test, "REQ-\\d+")) |>
    filter(!is.na(requirement)) |>
    arrange(requirement)
}

traceability()
#> # A tibble: 24 x 3
#>   file                    test                                          requirement
#>   <chr>                   <chr>                                         <chr>
#> 1 test-derive-trtemfl.R   REQ-042: events on or after first dose are...  REQ-042
#> 2 test-derive-trtemfl.R   REQ-042: events before first dose are not...   REQ-042

Then check that every requirement has at least one test:

requirements <- readr::read_csv("docs/requirements.csv")
untested <- setdiff(requirements$id, traceability()$requirement)
if (length(untested) > 0) {
  cli::cli_warn("Untested requirement{?s}: {.val {untested}}")
}

Assessing packages for regulated use

Before adding a package to a study, some due diligence.

library(riskmetric)

pkg_assess(pkg_ref("admiral")) |> pkg_score()
#> # A tibble: 1 x 3
#>   package  version  pkg_score
#>   <chr>    <chr>        <dbl>
#> 1 admiral  1.1.1        0.087      (lower is lower risk)

riskmetric assesses: test coverage, documentation completeness, bug closure rate, download counts, maintainer responsiveness, source control presence, R CMD check results, licence.

It is a screening tool, not a validation. What a risk assessment should actually consider:

Dimension Questions
Provenance Who maintains it? CRAN or a private repo?
Testing Coverage? Are the tests meaningful? Do they run in CI?
Documentation Complete help pages? Vignettes? A specification?
Community Downloads, open issues, time to close bugs
Stability How often does the interface break? Deprecation policy?
Purpose Is it used for a derivation that affects results, or for plotting?
Alternatives Could you write the 20 lines yourself and test them?

A pragmatic tiering:

Tier Examples Assessment
Base and recommended stats, utils, survival Accepted; part of R
Core, high-scrutiny dplyr, tidyr, haven, admiral, xportr Full risk assessment, version pinned
Supporting ggplot2, stringr, lubridate Lighter assessment
Convenience Anything used only interactively Not in the production environment

The tier depends on what the package does, not what it is. ggplot2 used to make an exploratory plot is low risk; ggplot2 used to produce a figure in the CSR is not.

Pin every version:

renv::snapshot()

And record the assessment outcome alongside the study documentation.

QC evidence

What a reviewer or auditor expects to see:

Evidence Artefact
Independent programming performed QC program, in version control, by a different author
Comparison performed diffdf output file, dated
Differences resolved A log with each difference, its cause and its resolution
Requirements tested Traceability matrix, test results
Environment recorded sessionInfo(), renv.lock, container digest
Code reviewed Pull request with a recorded approval
Change controlled Every change linked to a request

Generate what you can:

qc_report <- function(dataset, prod, qc, keys, output_dir = "output/qc") {
  result <- diffdf(prod, qc, keys = keys, suppress_warnings = TRUE)

  report <- list(
    dataset      = dataset,
    date         = Sys.time(),
    performed_by = Sys.info()[["user"]],
    prod_file    = attr(prod, "provenance")$git_sha %||% NA,
    qc_file      = attr(qc,   "provenance")$git_sha %||% NA,
    prod_rows    = nrow(prod),  qc_rows = nrow(qc),
    prod_vars    = ncol(prod),  qc_vars = ncol(qc),
    result       = if (diffdf_has_issues(result)) "DIFFERENCES FOUND" else "MATCH",
    r_version    = R.version.string,
    key_packages = purrr::map_chr(
      c("admiral", "dplyr", "haven"),
      ~ as.character(utils::packageVersion(.x))
    )
  )

  jsonlite::write_json(report,
    file.path(output_dir, sprintf("qc_%s_%s.json", dataset, format(Sys.Date(), "%Y%m%d"))),
    auto_unbox = TRUE, pretty = TRUE)

  capture.output(print(result)) |>
    writeLines(file.path(output_dir, sprintf("diffdf_%s.txt", dataset)))

  report
}

What R’s validation status actually is

A question that comes up constantly, and deserves a direct answer.

R itself is not “validated” — no software is validated in the abstract. Validation is something an organisation does to a system in its intended use. The R Foundation publishes a regulatory compliance document describing R’s development process, testing and quality practices, which supports an organisation’s own validation.

The FDA does not require SAS. The relevant guidance concerns data formats (SAS transport V5 for datasets) and the ability to reproduce analyses — not the software used. The FDA’s Statistical Software Clarifying Statement says explicitly that it does not require or endorse any particular software package.

Submissions using R have been accepted. The R Consortium’s R Submissions Working Group has completed several publicly documented pilot submissions to the FDA, including R-based ADaM datasets, TLFs and a Shiny application. Their materials are the best available reference.

What your organisation still has to do: qualify the environment, assess the packages, document the process, and produce the evidence above. That work is the same in any language.

Common mistakes

Mistake Consequence Fix
QC programmer reads the production code Not independent; misreadings propagate Spec only
Raising tolerance to hide a difference Real discrepancy suppressed Investigate first
Comparing RTF files Fails on timestamps, uninformative Compare the numeric data
Unit tests only for the happy path Edge cases break in production Test empty, NA, boundaries
No traceability to requirements Cannot demonstrate coverage Requirement IDs in test names
Treating riskmetric as validation Insufficient evidence It is a screening tool
No record of which version produced what Unanswerable audit question Tag, renv.lock, provenance stamp
Differences resolved verbally No evidence Written resolution log

Exercise 8.1 — Run and interpret a QC comparison

Two ADSL datasets differ. Write the comparison, then explain each difference type and how you would resolve it.

Show solution
library(diffdf); library(dplyr)

prod <- readRDS("data/adam/adsl.rds")
qc   <- readRDS("data/adam_qc/adsl.rds")

result <- diffdf(
  prod, qc,
  keys      = "USUBJID",
  tolerance = 1e-8,
  file      = "output/qc/diffdf_adsl.txt"
)
Differences found between the objects!

Not All Variables Compared Equal
  Variable  No of Differences
  TRTDURD                  3
  AGEGR1                  12
  DCSREAS                  1

Variables not in COMPARE
  VARIABLE
  RANDFL

Not all rows in BASE are in COMPARE
  USUBJID
  01-718-1427

Difference 1 — TRTDURD differs for 3 subjects

prod |>
  select(USUBJID, TRTSDT, TRTEDT, TRTDURD) |>
  inner_join(select(qc, USUBJID, TRTDURD_QC = TRTDURD), by = "USUBJID") |>
  filter(TRTDURD != TRTDURD_QC)
#>   USUBJID     TRTSDT     TRTEDT     TRTDURD TRTDURD_QC
#> 1 01-701-1015 2026-03-15 2026-09-12     182        183

182 vs 183 on every affected subject — a systematic off-by-one. One programmer used TRTEDT - TRTSDT, the other TRTEDT - TRTSDT + 1.

Resolution. Check the specification. ADaM defines TRTDURD as TRTEDT - TRTSDT + 1 (treatment duration in days, inclusive of both endpoints). QC is correct; production must be fixed. Add a unit test:

test_that("REQ-018: TRTDURD is inclusive of both endpoints", {
  expect_equal(compute_trtdurd(as.Date("2026-03-15"), as.Date("2026-03-15")), 1)
  expect_equal(compute_trtdurd(as.Date("2026-03-15"), as.Date("2026-03-16")), 2)
})

Difference 2 — AGEGR1 differs for 12 subjects

prod |>
  select(USUBJID, AGE, AGEGR1) |>
  inner_join(select(qc, USUBJID, AGEGR1_QC = AGEGR1), by = "USUBJID") |>
  filter(AGEGR1 != AGEGR1_QC) |>
  count(AGE, AGEGR1, AGEGR1_QC)
#>   AGE AGEGR1 AGEGR1_QC  n
#> 1  65  <65    65-80      8
#> 2  80  65-80  >80        4

Every difference is exactly at a boundary. One programmer used < and the other <=.

Resolution. The specification says “65-80” — read literally that is 65 <= AGE <= 80, making QC correct. But this is exactly the ambiguity that double programming exists to find: “65-80” and “≥65 to <80” are both plausible readings of a range written that way.

The right resolution is not to pick one — it is to get the specification amended to state the boundaries unambiguously (“≥65 and ≤80”), then fix whichever program is wrong. Document the ambiguity and the amendment; it will recur on the next study otherwise.

Difference 3 — DCSREAS differs for 1 subject

prod |> filter(USUBJID == "01-704-1266") |> pull(DCSREAS)
#> [1] "ADVERSE EVENT"
qc |> filter(USUBJID == "01-704-1266") |> pull(DCSREAS)
#> [1] "Adverse Event"

A case difference. One programmer applied str_to_upper(), the other did not.

Resolution. Check the spec and the controlled terminology. If DCSREAS is free text taken from DSTERM, it should be preserved as collected — production is wrong to upper-case it. If it is coded, the codelist decides. Either way, check_ct_data() from lesson 5 should have caught this, which suggests the codelist is missing from the specification.

Difference 4 — RANDFL present in production only

Production derived a variable QC did not. Either QC missed a specified variable, or production added an unspecified one.

Resolution. drop_unspec_vars() in the production program would have prevented this if RANDFL is not in the spec. If it is in the spec, QC has a gap. Check the spec first.

Difference 5 — one subject in production only

The most serious difference. A subject present in one dataset and not the other means the population definitions diverge.

setdiff(prod$USUBJID, qc$USUBJID)
#> [1] "01-718-1427"

dm |> filter(USUBJID == "01-718-1427") |> select(USUBJID, ARM, ARMCD, ACTARM)
#>   USUBJID     ARM            ARMCD    ACTARM
#> 1 01-718-1427 Screen Failure Scrnfail <NA>

A screen failure. One programmer included screen failures in ADSL, the other excluded them.

Resolution. This is a specification question, and both are defensible — ADaM permits either. The specification must state it, and the define.xml must describe it. Resolve by amending the spec, then aligning both programs.

The resolution log

Every difference needs a written record:

ID Variable Subjects Cause Resolution Who Date
D01 TRTDURD 3 Off-by-one; spec is inclusive Production corrected RG 2026-07-28
D02 AGEGR1 12 Spec ambiguous at boundaries Spec amended to “≥65 and ≤80”; production corrected RG/JS 2026-07-29
D03 DCSREAS 1 Case; spec has no codelist Codelist added to spec; production corrected RG 2026-07-29
D04 RANDFL all Not in spec Added to spec; QC corrected JS 2026-07-29
D05 (row) 1 Screen failure inclusion undefined Spec amended to exclude; production corrected RG/JS 2026-07-30
Note that four of the five resolutions amended the specification. That is the normal outcome of a good double-programming exercise, and it is the reason the activity is worth its cost.

Exercise 8.2 — Test suite for a derivation

Write a complete test suite for derive_agegr1(age, breaks, labels), including the boundary cases, and produce a traceability report linking tests to requirements.

Show solution

The function, with boundaries made explicit:

# R/derivations.R

#' Derive an age group category
#'
#' @param age Numeric vector of ages in years.
#' @param breaks Numeric vector of lower bounds for each group, ascending.
#'   Each group is `[break, next_break)` — lower bound inclusive, upper
#'   exclusive.
#' @param labels Character vector of labels, one per group.
#' @param missing_label Label for missing ages. Use `NA_character_` to leave
#'   them missing.
#' @return A character vector the same length as `age`.
#' @export
derive_agegr1 <- function(age,
                          breaks = c(-Inf, 18, 65, Inf),
                          labels = c("<18", "18-64", ">=65"),
                          missing_label = "Missing") {

  if (length(labels) != length(breaks) - 1) {
    cli::cli_abort(c(
      "{.arg labels} must have one fewer element than {.arg breaks}.",
      "x" = "Got {length(breaks)} break{?s} and {length(labels)} label{?s}."
    ))
  }
  if (is.unsorted(breaks, strictly = TRUE)) {
    cli::cli_abort("{.arg breaks} must be strictly increasing.")
  }
  if (!is.numeric(age)) {
    cli::cli_abort("{.arg age} must be numeric, not {.obj_type_friendly {age}}.")
  }

  out <- as.character(
    cut(age, breaks = breaks, labels = labels, right = FALSE)
  )
  out[is.na(age)] <- missing_label
  out
}

The test suite:

# tests/testthat/test-derive-agegr1.R

test_that("REQ-021: ages are assigned to the correct group", {
  expect_equal(derive_agegr1(10), "<18")
  expect_equal(derive_agegr1(30), "18-64")
  expect_equal(derive_agegr1(70), ">=65")
})

test_that("REQ-021: lower bounds are inclusive and upper bounds exclusive", {
  # This is the requirement that double programming most often disagrees on
  expect_equal(derive_agegr1(17), "<18")     # just below
  expect_equal(derive_agegr1(18), "18-64")   # exactly on the boundary
  expect_equal(derive_agegr1(64), "18-64")   # just below
  expect_equal(derive_agegr1(65), ">=65")    # exactly on the boundary
})

test_that("REQ-021: non-integer ages are handled at boundaries", {
  expect_equal(derive_agegr1(17.9), "<18")
  expect_equal(derive_agegr1(18.0), "18-64")
  expect_equal(derive_agegr1(64.9), "18-64")
  expect_equal(derive_agegr1(65.0), ">=65")
})

test_that("REQ-022: missing ages are labelled Missing", {
  expect_equal(derive_agegr1(NA_real_), "Missing")
  expect_equal(derive_agegr1(c(30, NA, 70)), c("18-64", "Missing", ">=65"))
})

test_that("REQ-022: the missing label is configurable", {
  expect_equal(derive_agegr1(NA_real_, missing_label = "Unknown"), "Unknown")
  expect_true(is.na(derive_agegr1(NA_real_, missing_label = NA_character_)))
})

test_that("REQ-023: custom breaks and labels are honoured", {
  out <- derive_agegr1(
    c(50, 70, 85),
    breaks = c(-Inf, 65, 80, Inf),
    labels = c("<65", "65-79", ">=80")
  )
  expect_equal(out, c("<65", "65-79", ">=80"))
})

test_that("the function is vectorised and preserves length and order", {
  ages <- c(10, 30, 70, NA, 18, 65)
  out  <- derive_agegr1(ages)
  expect_length(out, 6)
  expect_type(out, "character")
  expect_equal(out, c("<18", "18-64", ">=65", "Missing", "18-64", ">=65"))
})

test_that("empty input returns empty output", {
  out <- derive_agegr1(numeric(0))
  expect_length(out, 0)
  expect_type(out, "character")
})

test_that("extreme values are assigned to the outer groups", {
  expect_equal(derive_agegr1(0), "<18")
  expect_equal(derive_agegr1(120), ">=65")
  expect_equal(derive_agegr1(-1), "<18")     # implausible but must not error
})

test_that("mismatched breaks and labels are rejected", {
  expect_error(
    derive_agegr1(30, breaks = c(-Inf, 18, 65, Inf), labels = c("a", "b")),
    "one fewer element"
  )
})

test_that("unsorted breaks are rejected", {
  expect_error(
    derive_agegr1(30, breaks = c(-Inf, 65, 18, Inf), labels = c("a", "b", "c")),
    "strictly increasing"
  )
})

test_that("non-numeric input is rejected", {
  expect_error(derive_agegr1("30"), "must be numeric")
  expect_error(derive_agegr1(factor(30)), "must be numeric")
})

Traceability:

# tests/testthat/test-traceability.R

test_that("every requirement has at least one test", {
  requirements <- readr::read_csv(testthat::test_path("..", "..",
                                                      "docs", "requirements.csv"),
                                  show_col_types = FALSE)

  test_files <- list.files(testthat::test_path(), pattern = "^test-",
                           full.names = TRUE)
  all_tests <- unlist(lapply(test_files, function(f) {
    grep('test_that\\("', readLines(f), value = TRUE)
  }))
  tested <- unique(na.omit(stringr::str_extract(all_tests, "REQ-\\d+")))

  untested <- setdiff(requirements$id, tested)

  expect_equal(
    untested, character(0),
    info = paste("Requirements with no test:", paste(untested, collapse = ", "))
  )
})

The report:

traceability_report <- function() {
  reqs <- readr::read_csv("docs/requirements.csv", show_col_types = FALSE)

  tests <- purrr::map(list.files("tests/testthat", "^test-", full.names = TRUE),
    function(f) {
      lines <- grep('test_that\\("', readLines(f), value = TRUE)
      tibble::tibble(
        file = basename(f),
        test = stringr::str_match(lines, 'test_that\\("(.*?)"')[, 2]
      )
    }) |>
    purrr::list_rbind() |>
    mutate(id = stringr::str_extract(test, "REQ-\\d+"))

  reqs |>
    left_join(summarise(filter(tests, !is.na(id)),
                        n_tests = n(),
                        test_files = paste(unique(file), collapse = ", "),
                        .by = id),
              by = "id") |>
    mutate(n_tests = coalesce(n_tests, 0L),
           status = if_else(n_tests > 0, "COVERED", "NOT TESTED"))
}

traceability_report()
#> # A tibble: 4 x 5
#>   id      description                          n_tests test_files              status
#>   <chr>   <chr>                                  <int> <chr>                   <chr>
#> 1 REQ-021 Age groups <18, 18-64, >=65                3 test-derive-agegr1.R    COVERED
#> 2 REQ-022 Missing age labelled Missing               2 test-derive-agegr1.R    COVERED
#> 3 REQ-023 Age groups configurable per study          1 test-derive-agegr1.R    COVERED
#> 4 REQ-024 Age group order matches display order      0 NA                      NOT TESTED

The boundary tests are the ones that earn their keep. derive_agegr1(65) is exactly the value where two independent implementations diverge, and the test makes the convention explicit in a form a reviewer can read without opening the code.

REQ-024 showing as NOT TESTED is the report doing its job — that requirement concerns display order, which is not implemented in this function at all, and the gap is now visible rather than assumed covered.

Recap

  • Validation is system-level; verification is output-level; testing is code-level
  • Independent double programming means the QC programmer works from the spec, not the code
  • diffdf with the right keys; never raise tolerance to hide a difference
  • Compare computed numbers, not RTF files
  • Boundary and edge-case tests are where independent implementations diverge
  • Requirement IDs in test names make traceability a grep
  • riskmetric screens packages; it is not a validation
  • Most double-programming differences are resolved by amending the specification

Next: Define.xml preparation.

Back to top