Homepage

Testing validation logic in contacts/create command

Last edit: Apr 29, 2025


At this point, it's a good idea to add automated tests for the 'contacts/create' command's validation logic, so we don't have to check it manually each time.

Introduction to platformOS Tests Module

The platformOS Tests Module provides a way to write and run unit tests using Liquid.
It is designed for testing commands, validations, and even email-sending behavior in a modular and repeatable way.

Key Features

  • Run unit tests written in Liquid.
  • Validate data structures, function results, and error messages.
  • Simulate function calls and check results with built-in assertions.
  • Group multiple tests into a single file.

Working with Tests Module

All test files must be placed under lib/tests/.

File names must end with _test.liquid.

Test Structure

Each test:

  • Prepares input data if needed.
  • Calls a Liquid function or command.
  • Makes assertions using modules/tests/assertions/*.

Available Assertions

Assertion File Pass Condition (Expectation) Failure Condition (Error Trigger) Example Usage
blank object[field_name] is blank (nil, "", [], etc.) Field is not blank function contract = 'modules/tests/assertions/blank', contract: contract, object: data, field_name: 'optional_field'
presence object[field_name] has any non-blank value Field is blank function contract = 'modules/tests/assertions/presence', contract: contract, object: user, field_name: 'name'
not_presence object[field_name] is blank Field has a value function contract = 'modules/tests/assertions/not_presence', contract: contract, object: data, field_name: 'legacy_field'
true Value (or object[field_name]) is truthy Value is false/blank function contract = 'modules/tests/assertions/true', contract: contract, field_name: 'is_active', value: true
not_true Value is falsey/blank Value is truthy function contract = 'modules/tests/assertions/not_true', contract: contract, field_name: 'is_deleted', value: false
equal given == expected (exact match) given ≠ expected function contract = 'modules/tests/assertions/equal', contract: contract, field_name: 'email', given: user.email, expected: '[email protected]'
object_contains_object Every key:value in object_contains exists in given Missing key or mismatched value function contract = 'modules/tests/assertions/object_contains_object', contract: contract, given: profile, object_contains: '{"role": "admin"}'
invalid_object object.valid is false object.valid is true function contract = 'modules/tests/assertions/invalid_object', contract: contract, object: invalid_form, field_name: 'form'
valid_object object.valid is true object.valid is false function contract = 'modules/tests/assertions/valid_object', contract: contract, object: form, field_name: 'form_submission'
not_valid_object Same as invalid_object (expects invalidity) object.valid is true function contract = 'modules/tests/assertions/not_valid_object', contract: contract, object: invalid_data, field_name: 'data'

Key Notes:

  • Truthy/falsey: Follows Liquid’s logic (e.g., empty strings/arrays are falsey).
  • Blank: Explicitly checks for nil, "", [], or empty hashes.
  • valid_object vs invalid_object: Tests the valid flag on objects (common in form/validation contexts).
  • object_contains_object: Partial match (subset check) for nested structures.

All assertions receive and return a contract object that collects errors and tracks the number of checks performed. If a condition is not met, an error is registered in the contract.

Automatic testing

We will check the behavior of commands/contacts/create to ensure the command properly validates user input for a contact form.
Our tests will cover both valid and invalid inputs using various assertion types.

Install Tests Module

Let's start by installing the Test Module on our instance. Make sure you are in the project root, then run:


pos-cli modules install tests
pos-cli modules download tests

Deploy to your instance if needed:


pos-cli deploy staging

Now, visit the project page URL /_tests to open the test runner GUI:


PlatformOS tests module GUI with example test

Although the example test doesn't contain any assertions and always passes, you can run it to see how the test runner acts.

Create a directory for tests

Let's create a directory for your tests within the 'app' directory:


mkdir -p app/lib/tests

Add a test file - happy path scenario

This test covers the happy path - a case where the user input is valid and everything should work as expected. By verifying that valid input produces a correctly processed contact object, we create a baseline to confirm that the core logic functions properly before testing failure cases.
To do that, create a contact_create_happy_path_test.liquid file in app/lib/tests/ and add the following code:

app/lib/tests/contact_create_happy_path_test.liquid

{% comment %}
  Test: Valid input - should be considered valid
{% endcomment %}

{% liquid
  assign data = '{ "email": "[email protected]", "body": "This is a valid message body." }' | parse_json
  function contact = 'commands/contacts/create', object: data

  # 1. Contact should be valid
  function contract = 'modules/tests/assertions/valid_object', contract: contract, object: contact, field_name: 'contact'

  # 2. Email should be downcased
  assign expected_email = '[email protected]'
  assign given_email = contact.email
  function contract = 'modules/tests/assertions/equal', contract: contract, expected: expected_email, given: given_email, field_name: 'contact.email'

  # 3. Body should match
  assign expected_body = 'This is a valid message body.'
  assign given_body = contact.body
  function contract = 'modules/tests/assertions/equal', contract: contract, expected: expected_body, given: given_body, field_name: 'contact.body'

  # 4: Assert the contact.valid is true
  function contract = 'modules/tests/assertions/true', contract: contract, object: contact, field_name: 'valid'

  # 5: Errors should be blank
  function contract = 'modules/tests/assertions/blank', contract: contract, object: contact, field_name: 'errors'
%}

# 5: Debug print
<p>Contact object: {{ contact }}</p>

Let's break down the test:

1. Setup Test Input

assign data = '{ "email": "[email protected]", "body": "This is a valid message body." }' | parse_json

This line builds a test object (a mock user submission) with:

  • A valid email but in uppercase

  • A body with enough length to pass validation

This mimics what would be submitted from the contact form in views/pages/index.html.liquid

2. Call Main Command commands/contacts/create

function contact = 'commands/contacts/create', object: data

This line runs the entire contact creation flow, which chains:

build.liquid: Downcases email and passes through body

check.liquid: Validates email format, body length

Returns a contact object like:

{
  "email": "[email protected]",
  "body": "This is a valid message body.",
  "valid": true,
  "errors": {}
}

3. Validity Assertion

# 1. Contact should be valid
  function contract = 'modules/tests/assertions/valid_object', contract: contract, object: contact, field_name: 'contact'

Asserts that the final contact object contains "valid": true. This is the high-level success check.

4. Check Downcased Email

# 2. Downcased Email Assertion
  assign expected_email = '[email protected]'
  assign given_email = contact.email
  function contract = 'modules/tests/assertions/equal', contract: contract, expected: expected_email, given: given_email, field_name: 'contact.email'

This verifies that transformation in build.liquid succeeded and [email protected] was downcased to [email protected]

5. Body Match Assertion

# 3. Body should match
  assign expected_body = 'This is a valid message body.'
  assign given_body = contact.body
  function contract = 'modules/tests/assertions/equal', contract: contract, expected: expected_body, given: given_body, field_name: 'contact.body'

Verifies that the body text is passed through untouched.

4. Explicit valid is true

# 4: Assert the contact.valid is true
  function contract = 'modules/tests/assertions/true', contract: contract, object: contact, field_name: 'valid'

While valid_object assertion already verifies this, this adds an explicit boolean check — useful for tutorial purposes to show a different assertion provided in tests module.
It demonstrates how you can isolate and verify specific properties.

5. Errors Should Be Empty

# Step 4: Errors should be blank
  function contract = 'modules/tests/assertions/blank', contract: contract, object: contact, field_name: 'errors'

Checks that the errors hash is empty and validation didn’t raise any field-level issues.
This complements the valid: true check to confirm no internal warnings were generated by check.liquid.

6. Final Output: Debug Print

<p>Contact object: {{ contact }}</p>

This is not part of the test logic itself, but it helps visually debug the contact object after processing — especially useful during test development.

Run happy path test file

Now, visit your project page at /_tests and run the tests/contact_create_happy_path_test.
The test result screen will display:

  • The debug output we configured (which can be removed in the final version),
  • A summary of passed and failed assertions,
  • And the execution time.


PlatformOS tests module GUI with happy path test

Before moving on, take a moment to experiment with the test file — for example, try different assertions. This will help you become more familiar with the Tests Module and its logic.

Use the opposite assertion not_true where true is expected to check if tests will actually fail:

# 4: Assert the contact.valid is true
function contract = 'modules/tests/assertions/not_true', contract: contract, object: contact, field_name: 'valid'

Try using a different assertion and consider whether the test remains effective:

# 4: Assert the contact.valid is true
function contract = 'modules/tests/assertions/presence', contract: contract, object: contact, field_name: 'valid'

Note

Data Persistence: These are unit tests – they do not write to the database. The commands/contacts/create command only performs formatting and validation. It does not save data via GraphQL mutation (record_create) unless the execute.liquid file is called explicitly, which these tests intentionally avoid to keep tests isolated and fast.

Add a test file - negative path scenarios

Now, let’s add some negative test cases to our suite to verify that the 'contacts/create' command correctly handles various types of invalid input.

Create a file named contact_create_negative_path_test.liquid in app/lib/tests/ and add the following code:

app/lib/tests/contact_create_negative_path_test.liquid

{% comment %}
  Test: Invalid email - should be invalid
{% endcomment %}
{% liquid
  assign data = '{ "email": "invalid-email", "body": "This is a valid message body." }' | parse_json
  function contact = 'commands/contacts/create', object: data

  function contract = 'modules/tests/assertions/not_valid_object', contract: contract, object: contact, field_name: 'contact'
  function contract = 'modules/tests/assertions/presence', contract: contract, object: contact.errors, field_name: 'email'
%}

{% comment %}
  Test: Too short body - should be invalid
{% endcomment %}
{% liquid
  assign data = '{ "email": "[email protected]", "body": "Short" }' | parse_json
  function contact = 'commands/contacts/create', object: data

  function contract = 'modules/tests/assertions/not_valid_object', contract: contract, object: contact, field_name: 'contact'
  function contract = 'modules/tests/assertions/presence', contract: contract, object: contact.errors, field_name: 'body'
%}

{% comment %}
  Test: Too long body – should be invalid
{% endcomment %}

{% parse_json data %}
{
  "email": "[email protected]",
  "body": "{{ 201 | random_string }}"
}
{% endparse_json %}

{% liquid
  function contact = 'commands/contacts/create', object: data

  function contract = 'modules/tests/assertions/not_valid_object', contract: contract, object: contact, field_name: 'contact'
  function contract = 'modules/tests/assertions/presence',          contract: contract, object: contact.errors, field_name: 'body'
%}

{% comment %}
  Test: Blank email and body - should be invalid
{% endcomment %}
{% liquid
  assign data = '{ "email": "", "body": "" }' | parse_json
  function contact = 'commands/contacts/create', object: data

  function contract = 'modules/tests/assertions/not_valid_object', contract: contract, object: contact, field_name: 'contact'
  function contract = 'modules/tests/assertions/presence', contract: contract, object: contact.errors, field_name: 'email'
  function contract = 'modules/tests/assertions/presence', contract: contract, object: contact.errors, field_name: 'body'
%}

{% comment %}
  Test: Body is missing (null) – should fail validation
{% endcomment %}
{% liquid
  assign data = '{ "email": "[email protected]" }' | parse_json
  function contact = 'commands/contacts/create', object: data

  function contract = 'modules/tests/assertions/not_valid_object', contract: contract, object: contact, field_name: 'contact'
  function contract = 'modules/tests/assertions/presence', contract: contract, object: contact.errors, field_name: 'body'
%}

{% comment %}
  Test: Email missing from object – should fail validation
{% endcomment %}
{% liquid
  assign data = '{ "body": "This is valid" }' | parse_json
  function contact = 'commands/contacts/create', object: data

  function contract = 'modules/tests/assertions/not_valid_object', contract: contract, object: contact, field_name: 'contact'
  function contract = 'modules/tests/assertions/presence', contract: contract, object: contact.errors, field_name: 'email'
%}

{% comment %}
  Test: Completely empty object – both fields invalid
{% endcomment %}
{% liquid
  assign data = '{}' | parse_json
  function contact = 'commands/contacts/create', object: data

  function contract = 'modules/tests/assertions/not_valid_object', contract: contract, object: contact, field_name: 'contact'
  function contract = 'modules/tests/assertions/presence', contract: contract, object: contact.errors, field_name: 'email'
  function contract = 'modules/tests/assertions/presence', contract: contract, object: contact.errors, field_name: 'body'
%}

Test Cases Overview

1. Invalid Email Test

{% comment %}
  Test: Invalid email - should be invalid
{% endcomment %}
{% liquid
  assign data = '{ "email": "invalid-email", "body": "This is a valid message body." }' | parse_json
  function contact = 'commands/contacts/create', object: data

  function contract = 'modules/tests/assertions/not_valid_object', contract: contract, object: contact, field_name: 'contact'
  function contract = 'modules/tests/assertions/presence', contract: contract, object: contact.errors, field_name: 'email'
%}

  • Fails due to email format

  • Asserts valid: false

  • Ensures errors.email is present

2. Too Short Message Body

{% comment %}
  Test: Too short body - should be invalid
{% endcomment %}
{% liquid
  assign data = '{ "email": "[email protected]", "body": "Short" }' | parse_json
  function contact = 'commands/contacts/create', object: data

  function contract = 'modules/tests/assertions/not_valid_object', contract: contract, object: contact, field_name: 'contact'
  function contract = 'modules/tests/assertions/presence', contract: contract, object: contact.errors, field_name: 'body'
%}

  • Fails validation due to message being under 10 characters

  • Checks for errors.body

3. Too Long Message Body

{% comment %}
  Test: Too long body – should be invalid
{% endcomment %}

{% parse_json data %}
{
  "email": "[email protected]",
  "body": "{{ 201 | random_string }}"
}
{% endparse_json %}

{% liquid

  function contact = 'commands/contacts/create', object: data

  function contract = 'modules/tests/assertions/not_valid_object', contract: contract, object: contact, field_name: 'contact'
  function contract = 'modules/tests/assertions/presence',          contract: contract, object: contact.errors, field_name: 'body'
%}

  • Fails because body exceeds 200 characters

  • Confirms error on body

4. Blank Email and Body

{% comment %}
  Test: Blank email and body - should be invalid
{% endcomment %}
{% liquid
  assign data = '{ "email": "", "body": "" }' | parse_json
  function contact = 'commands/contacts/create', object: data

  function contract = 'modules/tests/assertions/not_valid_object', contract: contract, object: contact, field_name: 'contact'
  function contract = 'modules/tests/assertions/presence', contract: contract, object: contact.errors, field_name: 'email'
  function contract = 'modules/tests/assertions/presence', contract: contract, object: contact.errors, field_name: 'body'
%}

  • Fails both email and body validations

  • Ensures errors.email and errors.body are populated

We've tested contacts/create command against multiple invalid input scenarios. You can now run the negative path tests to verify that all validation rules are correctly enforced.

Handling Subtle Input Variations

Now, please add one more scenario at the end of our negative path test file:

app/lib/tests/contact_create_negative_path_test.liquid

{% comment %}
  Test: Email with upper case letters and leading/trailing spaces
{% endcomment %}
{% liquid
  assign data = '{ "email": "  [email protected]  ", "body": "Nice body here" }' | parse_json
  function contact = 'commands/contacts/create/build', object: data

  if contact.email != '[email protected]'
    function contract = 'modules/tests/helpers/register_error', contract: contract, field_name: 'email', message: 'Email was not trimmed or downcased'
  endif
%}

Now run the negative path tests again.


PlatformOS tests module GUI with negative path tests - failure

Looks like the test we just added failed. If you were paying close attention, you might already know why.
Based on our current implementation, create/build.liquid downcases the email, but it doesn’t trim it yet.
However, this test expects the email to be both downcased and trimmed of any extra spaces. Adding extra spaces is a common issue, so this reflects a real-world user behavior, when users are copying/pasting email addresses with extra spaces.
To fix this, let’s update 'build.liquid' to handle trimming as well.

Note

This test does not use assertion but extra logic and helper function to register an error, so the number of assertions in test report is still 16. This shows that flexible test logic can be written using Liquid’s native conditionals, helpful for edge cases or compound logic.

First, let's add a debugging line at the very end of contact_create_negative_path_test and run tests again:

<p>Contact object: {{ contact }}</p>

You should now see debug output like this:

{"email":" [email protected] ","body":"Nice body here"}

As you can expect, the email has been downcased, but it still has spaces around it— so we’ll need to implement trimming to meet our new requirement.

Fix the 'build.liquid' logic

Modify the build.liquid code as follows:

app/lib/commands/contacts/create/build.liquid

{% parse_json contact %}
  {
    "email": {{ object.email | strip | downcase | json }},
    "body": {{ object.body | json }}
  }
{% endparse_json %}

{% liquid
  return contact
%}

Here, we’ve added the 'strip' filter alongside 'downcase', ensuring that leading and trailing spaces are removed before the email is normalized.
Now run the tests again. This time, the debug output should look like:

{"email":"[email protected]","body":"Nice body here"}

With that, the email is now properly downcased and trimmed — and all the tests pass.

Summary

In this chapter, you learned how to write unit tests for the contacts/create command using the platformOS Tests Module. We covered both valid and invalid input scenarios using a variety of assertion types.

Also the Email is Downcased and Trimmed test case also showed how you can evolve your application logic by writing tests that reflect real-world user input—such as handling extra spaces and inconsistent email casing.

It highlights how:

  • Automated tests can guide feature development and refactoring.

  • Small improvements can be enforced through testing.

  • Missing requirements can be detected via test failures.

  • Correctness is confirmed by rerunning the tests.

  • This reinforces a test-first (or test-guided) mindset.

It also demonstates a powerful development workflow:
Write a test → see it fail → fix the logic → see it pass.
This cycle is the cornerstone of Test-Driven Development (TDD).




Congratulations on completing the Contact Us form tutorial! You have learned how to use platformOS to build a functional contact form, covering various concepts such as Commands/Business logic, Validators, Events, data display, logging and testing.

Tip

You can find the complete code for this tutorial in the tutorials-contact-us repository on GitHub.

Questions?

We are always happy to help with any questions you may have.

contact us