What is declarative programming?
It means to specify (declare) what to do without the how.
A more in-depth explanation is available on wikipedia.
Context
I have always found it easier to explain concepts using examples.
Suppose, we have a config class that reads and validates environment variables so that they can be used in some parts of the application.
Below is an example of such a config class.
Not-relevant code, such as the validation code and methods, have been removed.
export class AWSConfig {
readonly accessKeyID: string;
readonly secretAccessKey: string;
readonly region: string;
constructor() {
// ... code to validate environment variables
// assign validated variables to the respective properties
this.accessKeyID = '...';
this.secretAccessKey = '...';
this.region = '...';
}
// ... other methods
}
Like any good developer, there should be tests for this class.
The unit tests for the AWSConfig
class are as follows.
Most of the tests have been removed and a few are left to show the structure of the unit testing.
describe(AWSConfig.name, () => {
const env = { ...process.env };
const variables = {
AWS_ACCESS_KEY_ID: '...',
AWS_SECRET_ACCESS_KEY: '...',
AWS_REGION: '...',
};
beforeEach(() => {
process.env = { ...env, ...variables };
});
afterAll(() => {
process.env = { ...env };
});
it('should read from the environment variables', () => {
const config = new AWSConfig();
expect(config).toEqual({
accessKeyID: '...',
secretAccessKey: '...',
region: '...',
});
});
describe('region', () => {
it('should throw an error if value is invalid', () => {
process.env.AWS_REGION = 'invalid';
expect(() => new AWSConfig()).toThrowError('"AWS_ACCESS_KEY_ID" must be one of ["us-east-1", "us-east-2", ...]')
});
it('should be "us-east-1" if value is blank', () => {
process.env.AWS_REGION = '';
const config = new AWSConfig();
expect(config.region).toBe('us-east-1');
});
// ... more tests for region
});
// ... tests for the other properties
});
Suppose there are more *Config
classes such as AWSS3Config
, GoogleCloudConfig
, AzureConfig
, etc.
The unit tests for these *Config
classes are similar.
What typically happens in my team is that we end up doing a copy and paste and then modifying it for the specific config class.
This approach tends to be error-prone.
Is there a better approach? I believe that using declarative programming can help.
Solution
We could extract a reusable generic function (configTests
) for the logic and then specify and pass the tests to the configTests
function.
First, we defined the types and interfaces for the configTests
function, which helps to ensure type safety.
type ConfigClass<T> = new () => T;
interface ConfigTestCase<E = unknown> {
expected: E;
value?: string;
}
interface Error {
message: string | ((value?: string) => string);
}
interface ConfigErrorTestCase extends ConfigTestCase<Error> {
description: string;
}
interface ConfigTestCases<P, V> {
cases: {
error?: ReadonlyArray<ConfigErrorTestCase>;
good?: ReadonlyArray<ConfigTestCase>;
};
property: Extract<keyof P, string>;
variable: Extract<keyof V, string>;
}
type ClassProperties<T> = {
[K in keyof T as T[K] extends Function ? never : K]: T[K];
};
With the types and interfaces defined, the configTests
function can be implemented as follows.
The first parameter (Config
) is the class to be tested.
The second parameter (variables
) is the environment variables associated with the class.
The third parameter (properties
) is the properties of the config class.
The fourth parameter (cases
) is the test cases, which are split into two types of tests.
Errors test cases throw an error due to validation failure.
Good test passes the validation, e.g., using a default value if the environment variable is not defined or is a blank string.
export function configTests<T, V extends Record<string, string>>(
Config: ConfigClass<T>,
variables: V,
properties: ClassProperties<T>,
cases: ReadonlyArray<ConfigTestCases<ClassProperties<T>, V>>,
) {
return describe(Config.name, () => {
const env = { ...process.env };
beforeEach(() => {
process.env = { ...env, ...variables };
});
afterAll(() => {
process.env = { ...env };
});
it('should read from the environment variables', () => {
const config = new Config();
expect(config).toEqual(properties);
});
describe.each(cases)('$variable', ({ cases, property, variable }) => {
if (cases.error !== undefined && cases.error.length > 0) {
it.each(cases.error)(
'$#: should throw a ValidationError if the value $description',
({ expected, value }) => {
process.env[variable] = value;
expect(() => new Config()).toThrow(`"${property}" ${expected.message}`);
},
);
}
if (cases.good !== undefined && cases.good.length > 0) {
it.each(cases.good)(
'$#: should be "$expected" if the value is "$value"',
({ expected, value }) => {
process.env[variable] = value;
const config = new Config();
expect(config[property]).toBe(expected);
},
);
}
});
});
}
With the configTests
function defined, the following code is how the unit tests for the AWSConfig
can be written.
As we can see, all that is needed is to provide the data. The logic is hidden within the configTests
function.
configTests(
AWSConfig,
{
AWS_ACCESS_KEY_ID: '...',
AWS_SECRET_ACCESS_KEY: '...',
AWS_REGION: '...',
},
{
accessKeyID: '...',
secretAccessKey: '...',
region: '...',
},
[
{
cases: {
error: [
{
description: 'is blank',
expected: { message: 'is not allowed to be empty' },
value: '',
},
],
},
property: 'accessKeyID',
variable: 'AWS_ACCESS_KEY_ID',
},
{
cases: {
error: [
{
description: 'is blank',
expected: { message: 'is not allowed to be empty' },
value: '',
},
],
},
property: 'secretAccessKey',
variable: 'AWS_SECRET_ACCESS_KEY',
},
{
cases: {
error: [
{
description: 'is invalid',
expected: { message: 'must be one of ["us-east-1", "us-east-2", ...]' },
value: 'invalid',
},
],
good: [
{
expected: 'us-east-1',
value: '',
},
],
},
property: 'region',
variable: 'AWS_REGION',
},
],
);
The complete code is available at the TypeScript Playground here.
Conclusion
Using a copy-and-paste approach can be error-prone.
The declarative programming approach centralizes the logic in one location and enables it to be reused.
This avoids the pitfall of a cut-and-paste.
Top comments (0)