DEV Community

Christopher Zhong
Christopher Zhong

Posted on

Declarative Programming

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
}
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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];
};
Enter fullscreen mode Exit fullscreen mode

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);
                    },
                );
            }
        });
    });
}
Enter fullscreen mode Exit fullscreen mode

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',
        },
    ],
);
Enter fullscreen mode Exit fullscreen mode

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)