Skip to main content

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’s setup.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 repository

      • CONFIG - Pointer to the setup.cfg file for the repository

      • ROLES - 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 to BASEif LAB7DATA is not otherwise set

    • Adds the CLIENT path to the PYTHONPATH.

  • fixtures.py - File where content authors can declare pytest fixtures 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 (via setup_class), but see Content Fixtures below.

  • SEED class property - a list of seed files to use to seed the ESP instance prior to test execution, but see Content 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 (via setup_class) after SEED and IMPORT 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” for Lot 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 as name

  • string - same as name

  • number - generate a random (integer) number. Extra arguments:

    • digits: number of digits in in the number. Digits is only used if minval or maxval are not supplied. For instance, leaving minval and maxval empty with digits of 2 is equivalent to setting minval=10 and maxval=99.

    • minval: minimum value (inclusive)

    • maxval: maximum value (inclusive)

  • numeric - same as number

  • 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 as choice. Note that for dropdown columns, if type_ is not specified and choices 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” or 0 will fill the container by row. A value of "col" or 1 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, if by is ”row” and skip 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 for random(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 as ESP000001-B1 or ESP9610234-B9.

Additional verification expression examples:

  • 2020-07-13 - the cell value contains the string 2020-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

  1. Do not include protocols, workflows or other customer configuration object in the fixtures.

    1. Including the configs in the fixtures increases the risk of inadvertently overwriting changes made from the UI, either locally or in a customer environment

    2. Including the configs in fixtures makes it more difficult for customers to use the same tests as part of their IQ/OQ/PQ efforts.

    3. Including the configs in fixtures increases the likelihood of missing a required configuration in the content seed file.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

    1. Avoid: {{ x.startswith('abc') }}

    2. Good: {{ valid_expression(value) and value.startswith('abc') }}

    3. Good: {{ str(value).startswith('abc') }}

  6. valid_expression is preferred to using a check such as x == 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.