Automated Content Testing
Overview
Any ESP installation consists of two main pieces: the core ESP platform, and the content and configuration within that installation. From a customer’s perspective, it’s all “ESP”, so any workflow bug is perceived as a bug in the product. However, L7’s QMS-covered verification and validation activities are currently restricted to the core platform. Hence, it falls to individual customer projects to verify the content properly to ensure high-quality customer deliverables.
To accelerate testing, ESP provides facilities to quickly create automated workflow and workflow chain tests that use ESP APIs for experiment creation, data fill, pipeline execution, etc. and allow verification of values and more This document serves as a technical “deep dive” behind these capabilities. Additional information is available in the SDK Documentation.
ESP Automated Testing Capabilities
ESP Automated testing capabilities make use of the pytest python-based test runner version 5.2.0. In addition, it requires installing espclient. Once the these are installed, content tests can be executed the same as any other py.test, such as executing all at once or executing specific tests. See https://docs.pytest.org/en/stable/ for more information on pytest.
Important Files
A standard customer repository layout should have a “tests” directory at the top level. Within this directory are several files of note:
conftest.py
- this file is loaded by pytest and used to customize the pytest execution. A standard customer repository customizes pytest in the following ways:Adds the “esp.testing” module (part of espclient) as a pytest plugin
Sets the log level to error
Adds the following command line options:
-N/--no-import
Skip IMPORT definitions when running tests (see SDK documentation or section below on “content test structure”) (in some cases, it may be-I
instead of-N
).-C/--clean
Teardown content after running tests.-P/--port
Port of ESP server. When running against a local development environment, ESP’s testing facilities will read the port from the repository’ssetup.cfg
file so this option is only required when running against a remote ESP deployment.-S/--ssl
Run using SSL. This option is only required when running against a remote ESP deployment.-H/--host
Host for accessing ESP. This option is only required when running against a remote ESP deployment.-U/--email
username for accessing ESP. Useful for running tests as a non-admin user to ensure no admin-specific functionality was introduced.-X/--password
password for accessing ESP. Useful for running tests against a remote ESP deployment with a non-default admin password and for running tests as a non-admin user to ensure no admin-specific functionality was introduced.
Sets up the espclient connection parameters based on detected information from
setup.cfg
, defaults, and the provided CLI flags above.
__init__.py
- performs the following setup:Creates useful global test variables:
TESTS
- The testing directory (absolute path)RESOURCES
- Pointer to$TESTS/resources
BASE
- Pointer to$TESTS/..
, ie pointer to the top-level of the customer repositoryCONFIG
- Pointer to thesetup.cfg
file for the repositoryROLES
- Pointer to$BASE/roles
CONTENT
- Pointer to$BASE/content
COMMON
- Pointer to$BASE/app/esp-content/content
i.e. a pointer to the shared content library.CLIENT
- Pointer to$BASE/app/esp/client
i.e. a pointer to the repository’s espclient.
Sets the
LAB7DATA
environment variable toBASE
ifLAB7DATA
is not otherwise setAdds the
CLIENT
path to the PYTHONPATH.
fixtures.py
- File where content authors can declare pytestfixtures
that can be referenced by content tests.
Structure of a Content Test
A content test is a python object that follows pytest nomenclature that extends from both the standard python library object unittest.TestCase
and from esp.testing.ModelLoaderMixin
. For instance:
from unittest import TestCase from esp.models import Workflow from esp.testing import ModelLoaderMixin class TestIlluminaLibraryPrepWorkflow(ModelLoaderMixin, TestCase): def test_workflow_exists(self): """ Test Case ID Tests: * Requirement ID Setup: # None Steps: # Request the Illumina Library Prep workflow from ESP # Check whether the server returned the workflow Expectation: # The Illumina Library Prep workflow exists """ assert Workflow('Illumina Library Prep').exists()
Although the above test case could have been written without using ModelLoaderMixin. ModelLoaderMixin makes several facilities available to the test class for convenience.
MODEL
class property - sets the espclient model type under test. Not usually used for customer content tests.IMPORT
class property - a dictionary of model name-to-configuration file list. These model files will be imported at the beginning of the the test (viasetup_class
), but seeContent Fixtures
below.SEED
class property - a list of seed files to use to seed the ESP instance prior to test execution, but seeContent Fixtures
below.DATA
class property - a dictionary of model anem-to-configuration file list. The model files will be loaded at the beginning of the test (viasetup_class
) afterSEED
andIMPORT
and after fixtures have been resolved. The python for a modern ESP content test is often as simple as:
from unittest import TestCase from esp.models import Workflow from esp.testing import ModelLoaderMixin from . import RESOURCES class TestIlluminaLibraryPrepWorkflow(ModelLoaderMixin, TestCase): DATA = dict( Experiment=[ os.path.join(RESOURCES, 'IlluminaLibraryPrep_001.yml'), os.path.join(RESOURCES, 'IlluminaLibraryPrep_002.yml'), os.path.join(RESOURCES, 'IlluminaLibraryPrep_003.yml') ] )
Which would load three “Illumina Library Prep” experiments.
Any content loaded via these mechanisms are “tracked” and can be removed on test end or on next test execution. By default, content tests do not remove created content on test-end. This ensures content authors have an opportunity to review the experiments, worksheets, etc. after the automated tests complete.
Content Fixtures
“fixtures” allow pytest authors to establish test dependencies in a way that is re-usable across tests. ESP’s testing facilities leverage this capability to make it easy to set up ESP-specific testing dependencies. Using fixtures is the preferred way to set up any pre-conditions for your test, such as:
Reagent lots that must exist for a test
Sample hierarchies that must exist for a test
Projects that should exist prior to a test
Using fixtures, you can even create test-specific workflows on-the-fly, which can be useful for testing workflows that have external dependencies (e.g. that use tagged_value
or cell
to pull data from other workflows) without having to run samples through an entire workflow chain to test the one workflow of interest. Fixtures are generally preferred to using the SEED
and IMPORT
properties of tests because they are more efficient when running multiple tests that have the same set of dependencies. ESP pytest fixtures are declared using the fixtures
function from the esp.testing
module. The tests/fixtures.py
file should already have this function imported so in practice, most content tests simply add fixtures to that file, but it is also possible to add fixtures directly to test script files.
The fixtures
function takes the following arguments:
Seed file(s) - List of paths to seed files. The seed files form the initial “database” of reference able content for this
fixtures
call.Fixture declaration - dictionary where the keys are the names of a new fixture and the value is itself a dictionary that declares the fixture’s dependencies. For instance:
fixtures( seed=[ os.path.join(ROLES, 'seed', 'content.yml'), ], common=dict( project=[{'name': 'Verification Tests'}], )
In this example, we declare a new fixture “common” which depends on a project named “Verification Tests”. The ESP content testing infrastructure will now ensure that the project “Verification Tests” is created once and only once prior to executing any and all tests marked with
@pytest.mark.usefixtures('common')
.
Within the fixture dependency section, the keys are the names of esp client model objects, including content models:
configuration
task
pipeline
sample_type
container_type
item_type
protocol
workflow
workflow_chain
And data models:
sample
container
item
experiment
experiment_chain
project
The dependency values are always lists. The list items maybe either the name of an object declared in a content file referenced by the seed file(s), or may be a dictionary that will be passed to the create
class method of the corresponding model type, as was done in the “Verification Tests” project test above. This allows for complex creation of test-specific objects. Because the fixture file is a standard python file, any valid python expression can be used, including variables and function calls. A few dependency examples help illustrate the possibilities.
fixtures( [os.path.join(ROLES, 'seed', 'content.yml')], myfixture=dict( sample=[ { 'name': 'TestIndividual5', 'sample_type': 'Individual', 'children': [{ 'name': 'TestSubject5', 'sample_type': 'Subject', 'children': [ {'name': 'TestWB000005', 'sample_type': 'Whole Blood', 'children': [ {'name': 'TestBS000005', 'sample_type': 'Blood Spot'} ]}, {'name': 'TestDNA00005', 'sample_type': 'Extracted DNA'} ] }] }, { 'name': 'TestOrder000005', 'sample_type': 'Test Order', 'variables': { 'Test type': 'STR', 'Test Code': 'T1021', 'Test Status': 'Active', 'Subject': 'TestSubject5' } }) ) )
The above fixture declaration creates a new fixture “my fixture” which depends on the “TestIndividual5” entity and the ‘TestOrder000005’ entity. The TestIndividual5
entity will be created once for any tests that depend on the myfixture
fixture and that creation process will create the sample hierarchy:
TestIndividual5
TestSubject5
TestWB000005
TestBS000005
TestDNA00005
TestOrder000005
Note that the “Subject” property of the “Test Order” type is a resource link. Because the samples will be created in the order specified in the list, TestIndividual5 (and the hierarchy) will be created and present when TestOrder000005 is created, so the declaration may referencing it. When creating data models, all appropriate model configurations must be loaded. In the example above, the fixture is assuming the sample types “Test Order”, “Whole Blood”, “Extracted DNA”, “Blood Spot”, “Individual”, and “Subject” all exist already. These may exist by virtual of the seed file contents being pre-loaded into the target test system (preferred, see best practices), or they may themselves be declared as dependencies of the fixture. Fixture dependencies are resolved in a logical order that ensures, for instance, sample_type
dependencies are resolved prior to sample
dependencies that might require them.
In addition to the dependencies above, a fixture’s dependencies may also include the key “fixtures” which is a list of other fixture names. For instance:
fixtures( [os.path.join(ROLES, 'seed', 'content.yml')], dict( myfixture=dict( fixtures=['common'] ) ) )
The “myfixture” fixture now depends on the “common” fixture, so any tests that use the “myfixture” fixture will also have all of the “common” dependencies loaded. For additional information and examples, see the SDK documentation and the "Fixtures for Pytests" document.
YAML-driven Experiment configurations
A very common paradigm in ESP testing is to craft experiment data models as yaml files and load the data model via the DATA
attribute. Any additional testing or verification activities are then handled via standard python test methods. Experiment yaml files have a powerful assortment of capabilities. A basic example of an Experiment file is:
GlobalFiler STR Blood Spot 001: description: |+ Test standarad paths through global filer str BloodSpot workflow submit: true workflow: GlobalFiler STR Blood Spot project: Verification Tests tags: - verificationtest samples: - {name: TestBS000001, type: Blood Spot} - {name: TestBS000002, type: Blood Spot} - {name: 'BSC # 1', type: Inventory Item} - {name: 'NTC # 1', type: Inventory Item} protocols:
The above creates an experiment “GlobalFiler STR Blood Spot 001” of type “GlobalFiler STR Blood Spot” in the “Verification Tests” project with four samples: “TestBS000001”, “TestBS000002” of type “Blood Spot” and “BSC # 1” “NTC # 1” of type “Inventory Item”. It fills the data in the first two protocols of the workflow and checks the calculated values. Anything supported by Experiment.create
is supported within the yaml files. Formally, an experiment creation definition is:
Experiment Name: description: optional description. submit: boolean - must be true to fill protocol data workflow: name of workflow (required) project: name of project (required) tags: optional experiment tags samples: required. Value is any valid sample declaration that can be passed to Sample.create. protocols: list of protocol declarations.
Valid sample declarations include:
bulk creation (count + sample_type)
individual sample creation (list of samples, potentially with variable data)
list of existing samples (e.g. created via fixture) to use. Note that for this to work properly, you must specify both the sample name and the sample type. Otherwise, ESP will look for a sample of type “Generic sample” with the matching sample name, fail to find it, so it will create one.
Protocol declarations follow the form:
protocol name: complete: boolean (default: false) run: boolean (default: false) actions: - action block
Each action block can contain any of the following keys:
user/password - log in as specific user before performing other actions
load - Load tabular file into worksheet, matching file headers with protocol columns
data - Fill in data specified in configuration. Data can take the format of a dictionary of values (fill in all rows with the specified value), or a dictionary of lists (use the list indices to fill in individual rows).
wait - Wait a number of seconds before performing the next action
run - start a pipeline from a pipeline protocol. This option will run all pipelines specified for the worksheet protocol.
approve - Approve the protocol (approval columns)
verify - run assertions on data after data are entered into a worksheet. This action is particularly useful for verifying that expressions work as expected.
Action blocks are executed in the order they appear, allowing the experiment author to perform actions like change worksheet values (data), save and verify computed columns multiple times in succession, with a single experiment. Within a single action block, actions are executed in the order listed above. Note that the ESP content test infrastructure will save
the sheet after filling in data.
Often times, you only need a single action block. In these cases, the structure can be flattened:
- GlobalFiler Blood Spot Sample Setup: complete: true # for now, fill in concentration manually. data: Blood Spot Strg: ['Box 1', 'Box 1', 'NA', 'NA'] verify: Individual ID: ['{{ valid_expression(value) }}', '{{ valid_expression(value) }}', '{{ value is None}}', '{{ value is None}}'] Subject ID: ['{{ valid_expression(value) }}', '{{ valid_expression(value) }}', '{{ value is None}}', '{{ value is None}}'] Sample Type: [Blood Spot, Blood Spot, Bloodspot STR PCR Control, No Template Control] Batch ID: '{{ valid_expression(value) }}'
When using the flattened structure, the special keys “before” and “after” can also be used. For instance:
- My protocol: complete: true before: verify: My Column: My Value data: My Column: Set to a new value verify: My Other column: a different value after: data: My Other column: value 3
The above is exactly equivalent to:
- My protocol: complete: true actions: - verify: My Column: My Value - data: My Column: Set to a new value verify: My Other column: a different value - data: My Other column: value 3
The before
and after
keys predate the actions
key are are maintained for backwards compatibility. Of the available action keys, verify
, data
, and approve
deserve additional discussion.
Data Action
Column data can be provided in any of a number of formats, including mapping from a sample name to sample values, or mapping from a column name to sample values. Of those, the most commonly used format is to map from column names to sample values, as in the example above. The right-hand side of the column mapping should either be a list of values the same length as the number of samples in the worksheet, or a single value that will be applied to all samples in the worksheet. The column data mapping will perform automatic string → value conversion as-needed for different column types as follows:
String - as is
Numeric - as is
attachment:
If the value is a valid json string, use as-is
If the value refers to an existing file in ESP, build a reference to it
If the value refers to a file on disk, upload that and build a reference to it.
Note: once the file has been uploaded once, the previously-uploaded version will always be used. This sometimes confusing users if they find a mistake in their test file attachment, fix the mistake, and wonder why the test does not “respect” the fixed file. To rectify, archive the previously-uploaded file first.
location:
If the value is a string, it should be in the format “container name: slot[,slot,…]”. For instance:
“MyPlate: A01,A02,A03” would be a sample into wells “A01”, “A02”, and “A03” of container “MyPlate”.
If the value is a dictionary, it can map from container name to a list of wells:
data: My Location: My Plate: [A01, A02, A03]
If value is a dictionary, it can also include slot-level “field” data (dilution factor, etc), such as:
data: My Location: My Plate: - A01: {Injection Time: 8}, - A02: {Injection Time: 15}, - A03: {Injection Time: 21}
If value is a list of strings, it maps to the container: well
data: My Location Column: - 'My Plate: A01' - 'My Plate: A02'
Note that any containers referenced must already exist. You can use
fixtures
to create the plate in advance.
approval: will ignore the value and instead create a json string of the appropriate structure. Note: it is better to use the “approve” action than set an approval column via the
data
action.itemqtyadj: will convert the string “item name: quantity” to the appropriate structure:
Lot 1: 10
would add a usage entry of “10” forLot 1
of the appropriate item type.Making a list of strings will map one element in the list to each sample in the sheet
data: My Item Column: - 'Lot 1: 10' - 'Lot 2: 100'
resource_link:
currently only works with entity type = Sample
data: Resource Link Sample: '{"name": "sample3", "linkType": "Sample"}'
In addition, to the automatic conversions, values may be python expressions if the string(s) start with {{
and end with }}
. Any expression-generated values will still be subject to column-type auto-conversion. So, for instance: {{ “My Plate: “ + ','.join([“A01”, “A02”, “A03”])}}
would be the equivalent of My Plate: A01,A02,A03
. Valid expressions are any python function as well as the function random
. The random
function ties into the esp.data.generation
module of the client, allowing for rapid random value generation. For instance: random(type_="text", n=10)
Would generate 10 random text values. The testing tools will automatically supply n
equal to the number of samples in the worksheet if n
is not otherwise supplied. They will supply a value for type_
equal to the column type name if type_
is not otherwise supplied. Valid types, with available “extra arguments” are follows:
name
- generates a random name from a predefined list of first and last names.text
- same asname
string
- same asname
number
- generate a random (integer) number. Extra arguments:digits: number of digits in in the number. Digits is only used if
minval
ormaxval
are not supplied. For instance, leavingminval
andmaxval
empty withdigits
of 2 is equivalent to settingminval=10
andmaxval=99
.minval: minimum value (inclusive)
maxval: maximum value (inclusive)
numeric
- same asnumber
date
- generate a date. Extra arguments:start - earliest allowed date in the format “month/day/year” (e.g.: 03/31/2020)
end - latest allowed date in the same format as start.
choice
- generate values from a list of choices.. Extra arguments:choices - list of choices to choose from (e.g.:
random(type_='choice', n=10, choices=['Burrito', ‘Taco’, ‘Quesadilla’])
would generate 10 values chosen at random from the list “Burrito”, “Taco”, and “Quesadilla”.
dropdown
- same aschoice
. Note that for dropdown columns, iftype_
is not specified andchoices
is not specified,{{random()}}
will give you a random selection from the dropdown’s available options for each sample in the sheet.location
- Generate location values. Note that these are not truly random values, but auto-fill of container data. Extra arguments:containertype - the type of container to generate values for. E.g.: “96-well Plate”. Required.
by - A value of
”row”
or0
will fill the container by row. A value of"col"
or1
will fill the container by column. Default: 0.container - the name of a specific container to fill. If not specified, a random container name is generated and a new container will be created with that random name. The random name follows the pattern “ContainerTypeName{:06}” with a random 6-digit number. If
container
is specified, the container must already exist.skip - number of
by
to skip. For instance, ifby
is”row”
andskip
is 2, the first two rows will be skipped in the layout.
itemqtyadj
- Generate item use values. This works by selecting random existing lots of the specified item type. Extra arguments:itemtype - Name of item type. Required. Name of an existing item type.
quantity - how much of the item to use (per sample)
expired - If true, included expired lots in the available lot listing. Default: false.
checkbox
- Shortcut forrandom(type_=”choice”, choices=['true', ‘false’])
.
Multiple random
calls can be combined. For instance:
data: Concentration: '{{ random(minval=0, maxval=5, n=5) + random(minval=5.1, maxval=10, n=5)}}
would create a list of 10 random number, the first 5 less than 5, the last 5 greater than 5.
Verify Action
The verify action allows you to check the contents of the protocol to ensure the data conforms to expectations. All verifications in a verify block are performed and any/all failures collected and reported on, so if you have multiple errors, you will see them all at once and can fix them all at once.
The structure of the verify block is:
verify: <columnname>: value # for instance: Batch ID: Batch1 <columnname>: [list, of values] # for instance: QC Status: ['Pass', 'Pass', 'Fail'] <columnname>__options: [list, of, options] # for instance: QC Status__options: ['Pass', 'Fail'] <columnname>__options: [[first, list], [second, list], [third, list]]
The <columnname>__options
form checks that the column-level options for a dropdown box match the list. When the value is a single list, the dropdown options for each sample are checked to match that list. If it is a list of lists, the outer list must be the same length as the number of samples and the dropdown options in the worksheet are checked against the values in the list in sample order. I.e., the first row in the worksheet must have a list of options that matches the first list, etc. The option list values must be strings.
The verification for a column may be a single value or a list of values. When it is a single value, the same value is used to check that column for every sample in the protocol. When it is a list of values, the list length must be the same as the number of samples in the protocol. The values may be any valid yaml literal. They may also be an expression string in the form: {{ <expression> }}
where <expression>
is any valid python boolean expression. In addition to standard python functions and operators, the following objects are available in the evaluation environment:
value
- the value of the cell being verified.valid_expression
- a function that checks whether a column has a value. For instance:{{ valid_expression(value) }}
- verify the cell has a non-null, non-empty value{{ valid_expression(value) and “admin” in str(value) }}
- verify the cell has a non-null, non-empty value and contains the string “admin”.
eq
- floating point equality within a particular decimal tolerance"{{ eq(value, 2.5, 2) }}"
- verify the value equals 2.5, within 2 decimal places
row
: The current worksheet row, with access to all column values for the sample. For instance:{{ row["z"] == row["x"] + row["z"] }}
match
: Match the value with a regular expressions. For instance:{{ match(r'^ESP[0-9]+-B[0-9]$', 'value') }}
- Pattern match for values such asESP000001-B1
orESP9610234-B9
.
Additional verification expression examples:
2020-07-13
- the cell value contains the string2020-07-13
"14"
- the cell contains the string 14.{{ value == "14" }}
- equivalent to the literal"14"
'{{ value == "14" }}'
- verify one number equals another number"{{ str(value).lower() == 'true' }}"
- verify a checkbox column is selected/unselected (if false)"{{ str(value).endswith('xyz') }}"
- verify the value ends with xyzzy"{{ str(value).startswith('abc') }}"
- verify the value starts with abc
Approve Action
approve:
column name: user password
approve: Lab Manager Approval: password
Best Practices
Do not include protocols, workflows or other customer configuration object in the fixtures.
Including the configs in the fixtures increases the risk of inadvertently overwriting changes made from the UI, either locally or in a customer environment
Including the configs in fixtures makes it more difficult for customers to use the same tests as part of their IQ/OQ/PQ efforts.
Including the configs in fixtures increases the likelihood of missing a required configuration in the content seed file.
All expression columns should be verified. At a minimum, a verification of
{{ valid_expressions(value) }}
ensures at least that the value is not blank or null.Include sufficient sample and data variability to cover all edge cases in computed values. For instance, a workflow with a QC pass/fail calculation such as
{{ “PASS” if cell(“Concentration”) <= 10 else “FAIL” }}
should have samples with concentration of at least 9.9, 10, and 10.1 to to verify expected QC status values of “FAIL”, “FAIL”, and “PASS”, respectively.Each workflow chain transition rule should be exercised in both the “positive” and “negative” to ensure samples transition when they should and don’t otherwise. The exception to this is when using
is_node_selected()
as the rule for all transitions. In this case, it’s sufficient to test at least two paths to make sure the selection is being honored properly and to test that default values for the next workflow selection column are set properly.When performing verification, cast the value to string before running string functions. This will result in better error reporting in cases where the value was empty/null when you weren’t expecting it to.
Avoid:
{{ x.startswith('abc') }}
Good:
{{ valid_expression(value) and value.startswith('abc') }}
Good:
{{ str(value).startswith('abc') }}
valid_expression
is preferred to using a check such asx == null or x == ''
. It is more thorough in its checks and will eventually be updated to check whether any expression evaluation errors occurred and, if so, report the errors.