DRY vs DAMP, writing automated tests

by Jack Pritchard

Hey! Just so you know, this article is over 2 years old. Some of the information in it might be outdated, so take it with a grain of salt. I'm not saying it's not worth a read, but don't take everything in it as gospel. If you're curious about something, it never hurts to double-check with a more up-to-date source!

DRY

#

If you're a developer, you may have come across the term "Don't Repeat Yourself" (DRY) in programming. DRY is commonly referenced when discussing the reusability of code, with a focused importance of avoiding duplication and code repetition.

Removing duplication guarantees that all concept in a system have a single definitive representation. A alteration to any logic for the business needs a single change. Simply put, DRY improves the maintainability of your systems.

In fact, abstraction and reusability are some of the core engineering concepts you will need to learn. The concepts can separate junior developers from mid-level - senior developers.

I would even argue that if you can't learn the skill of writing DRY it will be impossible to progress your career when working as part of a team. Learn DRY now, and you'll grow in your career path (as long as it's in software engineering).

DRY in practice

#

To illustrate the concept of DRY I have created two code examples. The examples both solve the same objective. To create an array of users for a web product that match a type defined pattern of an object with two fields:

  1. a boolean on if the user has been approved
  2. a string containing the user's name

Repetition

#

In this example, we develop a solution that constructs each user with an explicitly defined set of field values. For each user, we have to specify whether or not the user is approved, and then their name.

type User = {
approved: boolean;
name: string;
};

const jack: User = {
approved: true,
name: `jack`,
};

const jill: User = {
approved: false,
name: `jill`,
};

const bill: User = {
approved: false,
name: `bill`,
};

const users = [jack, jill, bill];

This code is fine but it doesn't follow DRY principles.

We are repeating the field 'approved' with the value 'false' for all users other than Jack. It could instead be argued that we should default every user to not be approved. This is unless we know we want the user to be approved.

Reusing Logic

#

Instead, take our new reusable code, where we are generating users with a function that returns the expected User model. In our function, we assume the user is not approved by default. Using an implicit approach instead of explicit.

However, we allow the option to override an approved status when creating a user. This is because we are spreading the arguments at the end of the function. Allowing us to remove the requirement for explicit values, but still providing us an option to use explicit values if needed. Fewer requirements, same functionality.

type User = {
approved: boolean;
name: string;
};

type CreateUser = {
approved?: boolean;
name: string;
};

const createUser = (details: CreateUser): User => ({
approved: false,
...details,
});

const jack = createUser({
approved: true,
name: `jack`,
});

const jill = createUser({
name: `jill`,
});

const jane = createUser({
name: `jane`,
});

const bill = createUser({
name: `bill`,
});

const users = [jack, jill, jane, bill];

Every time we create a user, we now only need to define the name of the user. Their name is the only required data we need.

All of the object values for if the user is approved (false as default) is no longer defined several times. Instead, we define it once and allow the option to override if needed.

DAMP

#

Descriptive and Meaningful Phrases (DAMP) in programming is an approach to code that requires the code to be readable and clear to all readers (especially other developers) in how it works.

To maintain code, you first need to understand the code. To understand it, you have to read it. Consider for a moment how much time you spend reading code. It's a lot.

DAMP increases maintainability by reducing the time necessary to read and understand the code.

A really simple example of this is simply how we name our values or functions. Take our list of users, and some poor function naming.

Instead of an explicit function name we are using a generic ambigious function name 'create'. Our function technically still works the same. We also get the benefit of not needing to type out a longer function name any time we call it.

const make = overrides => ({
// approved
a: false,
// name, anything else...
...overrides,
});

const jack = make({
a: true,
n: `jack`,
});

const jill = make({
n: `jill`,
});

const bill = make({
n: `bill`,
});

const list = [jack, jill, bill];

Now while the shortned function name may make sense to the developer who wrote the code, it may take some effort by other developers to understand what is being created by the function.

Sure, you could argue that if we are passing in a name and have named values like 'jack' or 'jill' that it's obvious we are making users. That's not always the case though, if this was a large application we could be creating customer references for our users (e.g. jack is a customer of an existing user).

Instead of using a vague name for our function like 'create', it would be better to describe it with an explicity 'createUser' as we are creating user accounts for our application.

const createUser = overrides => ({
approved: false,
...overrides,
});

const jack = createUser({
approved: true,
name: `jack`,
});

const jill = createUser({
name: `jill`,
});

const jane = createUser({
name: `jane`,
});

const bill = createUser({
name: `bill`,
});

const list = [jack, jill, jane, bill];

Much more clear, and easy to understand when immediately looking at the code and gaining context.

DRY & DAMP, not DRY vs. DAMP

#
It's a balance, not a contradiction. DAMP and DRY are not contradictory, rather they balance two different aspects of a code's maintainability. Maintainable code (code that is easy to change) is the ultimate goal here.

You will at times find parts of your code that can be abstracted to keep it DRY but at the expense of sacrificing DAMP code. Neither have absoloute priority over each other, and where possible you should balance the two.

That being said, there are some places where you could argue one has priority over the other.

Testing

#

When writing unit tests, end-to-end tests, and integration tests it is fundamental to keep the tests descriptive and meaningful.

You do not want to be writing complicated functions that recurssively generate your tests. You should when necessary sacrifice DRY to keep your tests DAMP. This is because when working as part of a wider team you need anyone to be able to inspect failing tests without learning how you've set them up.

Creating complicated logic inside of your tests can create more problems than it solves. Your tests should be testing your code logic, by writing further logic in your tests you would need tests for your tests!

enum Currency {
GBP = 'GBP',
USD = 'USD',
}

describe('Compare Currencies', () => {
const flow = ({
allow = true,
expected,
input,
}: {
allow: boolean;
expected: Currency;
input: Currency;
}) => {
it(`should ${allow ? `allow': `prevent`} as the currencies ${
allow ? 'do': `don't`
} match`, () => {
const matched = expected === input;
expect(matched).toStrictEqual(allow);
});
};

flow({ allow: true, expected: Currency.GBP, input: Currency.GBP });
flow({ allow: false, expected: Currency.GBP, input: Currency.USD });
flow({ allow: true, expected: Currency.USD, input: Currency.USD });
});
enum Currency {
GBP = 'GBP',
USD = 'USD',
}

describe('Compare Currencies', () => {
it(`should allow as the currencies do match`, () => {
expect(Currency.USD).toStrictEqual(Currency.USD);
expect(Currency.GBP).toStrictEqual(Currency.GBP);
});

it(`should prevent as the currencies don't match`, () => {
expect(Currency.USD).not.toStrictEqual(Currency.GBP);
expect(Currency.GBP).not.toStrictEqual(Currency.USD);
});
});