Skip to content

Instantly share code, notes, and snippets.

@joelove
Created September 5, 2018 16:56
Show Gist options
  • Save joelove/894afe720c7958a287985aac05da49b9 to your computer and use it in GitHub Desktop.
Save joelove/894afe720c7958a287985aac05da49b9 to your computer and use it in GitHub Desktop.

Writing tests for validators

I've broken down the validation architecture in to its absolute simplest form so we can use it to understand and compare testing approaches.

The validation architecture

Let's say we have a set of simple validation functions that fail whatever you pass in:

validations.js

export const validation1 => false;
export const validation2 => false;
export const validation3 => false;

And we create a validator. This is a word we use for defining a dictionary of field names and their validation functions:

validation-dictionary.js

import { validationDictionary, addValidationToDictionary } from './validation-dictionary-builder';

import { validation1, validation2, validation3 } from './validations';

addValidationToDictionary('field1', validation1);
addValidationToDictionary('field1', validation2);
addValidationToDictionary('field2', validation3);

export validationDictionary;

When you import this file and inspect the value of validationDictionary it looks like this:

{
  field1: [validation1, validation2],
  field2: [validation3],
}

That's because addValidationToDictionary is just a helper function that adds properties to the validationDictionary object literal, like this:

validation-dictionary-builder.js

export const validationDictionary = {};

export const addValidationToDictionary = (key, value) => {
  validationDictionary[key] = [
    ...get(validationDictionary, key, []),
    value,
  ]
}

This is very important. Each validator is just a prettier, more explicit way of defining an object literal. Everything would continue to work correctly if the validator looked like this:

import { validation1, validation2, validation3 } from './validations';

return {
  field1: [validation1, validation2],
  field2: [validation3],
};

Next, we want to take our validations dictionary and compare each of the functions against the matching field in a set of data. We create a function to do this which checks each validation function against each matching value:

validate-data-structure.js

export const validateDataStructure = (data, dictionary) => (
  mapValues(dictionary, (validations, key) => (
    allPass(validations)(data[key])
  ))
);

Now we have everything we need. So we can take a data structure, and run it through validateDataStructure() with the validation dictionary:

import { validateDataStructure } from './validate-data-structure';
import { validationDictionary } from './validation-dictionary';

const dataStructure = {
  field1: 'foo',
  field2: 'bar',
};

validateDataStructure(dataStructure, validationDictionary);

Which returns a validation result:

{
  field1: false,
  field2: false,
}

Testing all the things

As shown above, the validation framework has four types of thing:

  • Validation functions

    Each function takes a single value and returns true or false.

  • Validation function dictionaries

    Each dictionary has a list of field keys each with a list of validation functions.

  • A helper utility to build validation dictionaries

    The function takes a field key and a validation function and adds them to an object literal, that then becomes a validation dictionary.

  • A function that runs a validation dictionary against a set of data

    This is where the heavy lifting happens. The function loops through each key in the validation dictionary and runs each validation function against the field in the data with the same key, returning an object with the same keys as the dictionary and values matching the return value of the matching validation functions.

Let's write a unit test for each thing individually:

validations.spec.js

import { validation1, validation2, validation3 } from './validations';

describe('validations', () => {
  describe('validation1', () => {
    it('returns false when the value is a string', () => {
      expect(validation1(string.generate())).toEqual(false);
    });

    it('returns false when the value is null', () => {
      expect(validation1(null)).toEqual(false);
    });

    it('returns false when the value is undefined', () => {
      expect(validation1()).toEqual(false);
    });
  });

  ...
});

validation-dictionary.spec.js

import { validationDictionary } from './validation-dictionary';
import { validation1, validation2, validation3 } from './validations';

describe('validationDictionary', () => {
  expect(validationDictionary).toEqual({
    field1: [validation1, validation2],
    field2: [validation3],
  })
});

validation-dictionary-builder.spec.js

import { validationDictionary, addValidationToDictionary } from './validation-dictionary-builder';

describe('validationDictionaryBuilder', () => {
  let field1;
  let field2;
  let validation1;
  let validation2;

  beforeEach(() => {
    field1 = string.generate();
    field2 = string.generate();
    validation1 = () => string.generate();
    validation2 = () => string.generate();
  })

  afterEach(() => {
    validationDictionary = {};
  });

  describe('when a single validation is added', () => {
    beforeEach(() => {
      addValidationToDictionary(field1, validation1);
    });

    it('appends the validation function to the correct key in the dictionary', () => {
      expect(validationDictionary).toEqual({
        [field1]: [validation1],
      });
    })
  });

  describe('when multiple validations are added to a single field', () => {
    beforeEach(() => {
      addValidationToDictionary(field1, validation1);
      addValidationToDictionary(field1, validation2);
    });

    it('appends the validation functions to the correct key in the dictionary', () => {
      expect(validationDictionary).toEqual({
        [field1]: [validation1, validation2],
      });
    });
  });

  describe('when multiple validations are added to separate fields', () => {
    beforeEach(() => {
      addValidationToDictionary(field1, validation1);
      addValidationToDictionary(field2, validation2);
    });

    it('appends the validation functions to their respective keys in the dictionary', () => {
      expect(validationDictionary).toEqual({
        [field1]: [validation1],
        [field2]: [validation2],
      });
    });
  });
});

validate-data-structure.spec.js

import { validateDataStructure } from './validate-data-structure';

describe('validateDataStructure', () => {
  let validationDictionary;
  let dataStructure;
  let result;

  let field1;
  let field2;
  let truthyValidation;
  let falsyValidation;

  beforeEach(() => {
    field1 = string.generate();
    field2 = string.generate();
    truthyValidation = () => true;
    falsyValidation = () => false;
  });

  describe('when there is one failing validation but no data', () => {
    beforeEach(() => {
      validationDictionary = {
        [field1]: [falsyValidation],
      };

      dataStructure = {};

      result = validateDataStructure(dataStructure, validationDictionary);
    });

    it('has an error matching the field key', () => {
      expect(result[field1]).toBe(true);
    });

    it('has a length of 1', () => {
      expect(result.length).toBe(1);
    });
  });

  describe('when there is one failing and one passing validation but no data', () => {
    beforeEach(() => {
      validationDictionary = {
        [field1]: [truthyValidation],
        [field2]: [falsyValidation],
      };

      dataStructure = {};

      result = validateDataStructure(dataStructure, validationDictionary);
    });

    it('has an error matching the field key', () => {
      expect(result[field2]).toBe(true);
    });

    it('has a length of 1', () => {
      expect(result.length).toBe(1);
    });
  });

  ...
});

End-to-end testing

"But how do I end-to-end test this whole system?!", I hear you say!

"What if I want to take a big lump of form data and a real dictionary of validation functions and check that the right errors are coming back?!"

Let's write an integration test mother f**kers. 💥

account-form-validation.integration.spec.js

import accountFormValidator from './validators/account-form';

import {
  accountFormDataFilled,
  accountFormDataPartial,
  accountFormDataEmpty,
} from './fixtures/account-form';

describe('account form validation', () => {
  describe('when the form is fully filled out', () => {
    beforeEach(() => {
      result = validateDataStructure(accountFormDataFilled, accountFormValidator);
    });

    it('returns no errors at all', () => {
      expect(result).toEqual({});
    });
  });

  describe('when the form is partially filled out', () => {
    beforeEach(() => {
      result = validateDataStructure(accountFormDataPartial, accountFormValidator);
    });

    it('returns errors just for the failing fields', () => {
      expect(result).toEqual({
        accountNumber: true,
        createdDate: true,
        isConverted: true,
        isInherited: true,
        status: true,
      });
    });
  });

  describe('when the form is partially filled out', () => {
    beforeEach(() => {
      result = validateDataStructure(accountFormDataPartial, accountFormValidator);
    });

    it('returns no errors at all', () => {
      expect(result).toEqual({
        accountBalance: true,
        accountFeatures: true,
        accountId: true,
        accountNumber: true,
        accountType: true,
        applicationConsent: true,
        clientId: true,
        clientNumber: true,
        communicationDeliveryConsent: true,
        contingentBeneficiaries: true,
        createdDate: true,
        establishedDate: true,
        isConverted: true,
        isInherited: true,
        primaryBeneficiaries: true,
        promoCodes: true,
        providerName: true,
        status: true,
        systemOfRecord: true,
        tags: true,
        termsAgreementConsent: true,
      });
    });
  });
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment