What functions deserve tests?

A special case of this is testing an optimised version against a (known working :wink: “slower” version of a function/module/…

1 Like

Writing unit tests is tedious… Why not spend the limited time budget on writing functionality tests to cover more of the use-case space? Honest question. I know the in-principle reason is that testing factored components is an exponential (in the number of components) improvement of state space coverage. But good luck spelling out the quirky subcomponent inputs that arise in the interesting end-to-end situations.

Unit tests are faster to run. If you write them with proper mocking, meaning no file/db/network access, you can run them in parallel too. :slight_smile: Faster to run = devs can run them locally after each change to get quick feedback.

2 Likes
async function uploadFiles(user, folder, files) {
  const dbUser = await readUser(user);
  const folderInfo = await getFolderInfo(folder);
  if (await haveWriteAccess({dbUser, folderInfo})) {
    return uploadToFolder({dbUser, folderInfo, files });
  } else {
    throw new Error("No write access to that folder");
  }
}

Should we unit test uploadFiles (copied from Mocking is a Code Smell)? If yes, how would you write the test (what would you mock)?


My opinion: there’s no need to unit test uploadFiles.

The reason is that the implementation of uploadFiles is declarative. A declarative implementation is equivalent to a specification. Since the implementation matches the specification perfectly, no unit test is needed.

When do we need to test?

We need to test a function if its implementation is not equivalent to its specification, e.g., a sort function.

I’d appreciate any thought on this one. @dbuenzli @shonfeder @Release-Candidate @benjamin-thomas @Chet_Murthy @fdagnat @bluddy

Shouldn’t that be:

async function uploadFiles(user, folder, files) {
  const [dbUser, folderInfo] = await Promise.all([readUser(user), getFolderInfo(folder)]);

  if (await haveWriteAccess({dbUser, folderInfo})) {
    return uploadToFolder({dbUser, folderInfo, files });
  } else {
    throw new Error("No write access to that folder");
  }
}

:slight_smile:

Also, haveWriteAccess seems like the core logic here, that should definitely be tested. Also, usually this kind of core logic would not be doing I/O, it would operate purely on its inputs. So typically we shouldn’t need to await it.

I don’t think your implementation is declarative.

What happens if dbUser fails: unknown
What happens if I can’t get the folder info: unknown.
Etc.

Also, I think you’re mixing business logic with state manipulating functions: I would try to avoid that.

I’m personally not very interested in mocking. I would try to model my program like so:

type User = { userId: number, name: string; };
type Rights = { admin: boolean; } | { read: boolean, write: boolean; }

function hasWriteAccess(user: Rights): boolean {
    if ("admin" in user) {
        return user.admin
    } else {
        return user.read && user.write
    }
}

type UploadRequestOutcome = { tag: 'allowed'; } | { tag: 'failure', reason: string; };

function uploadFilesRequest(rights: Rights): UploadRequestOutcome {
    if (hasWriteAccess(rights)) {
        return {tag: 'allowed'}
    } else {
        return {tag: 'failure', reason: "no write access"}
    }
}

async function saveFiles(files: any): Promise<string> {
    console.log("Saving files", files)
    return Promise.resolve("OK")
}

async function doUploadFile(rights: Rights, files: any): Promise<string> {
    const request = uploadFilesRequest(rights)
    switch (request.tag) {
        case "allowed":
            const msg = await saveFiles(files);
            return msg;
        case "failure":
            console.log('Something went wrong', request.reason)
            return "WAT";

        default:
            console.log("Something is very wrong!")
            return "WAAAAT";
    }
}

async function getUser(): Promise<User> {
    return Promise.resolve({userId: 1, name: "John Doe"})
}

async function getUserPerms(user: User): Promise<Rights> {
    // fetch db stuff
    if (user.name == "John Doe") {
        return Promise.resolve({admin: true});
    } else {
        return Promise.resolve({read: true, write: false})
    }
}

// Main starts here!

console.log('Program start...')
const user = await getUser();
// console.log({user})
const perms = await getUserPerms(user);
// console.log({perms})
const request = await uploadFilesRequest(perms)
await doUploadFile(perms, ['a', 'b', 'c']);
console.log('Program finished!')

Now there are many things wrong here (no error handling mainly) and it’s a toy example sure but still, I think it gives the overall idea.

So the core logic can be easily tested this way, no need to mock.

Now the question is does hasWriteAccess need testing or is it clear enough as-is? Is the program clear enough such that you’re fairly certain you’re not going to upload files without proper permissions. If the business logic become complicated, is there value in testing the output of uploadFilesRequest?

Do you really need to test saveFiles?

Anyways, all this is easier said than done. The architecture pattern I heard years ago was “functional core, imperative shell”: you test the functional core (mainly), and not so much the imperative shell (where side effect happens). It took a while to digest, and I’m still digesting :slight_smile:

1 Like

I just copied the code from the article. I guess the code was only for demonstration, so let’s not worry about details too much :slight_smile:.

Your “Main starts here!” portion is essentially the uploadFiles function I copied. Do you think this portion of code in your version should be unit tested (with mocks)? My answer is still no because the code is declarative.

I learned about “functional core, imperative shell” several months ago from Programming Without State Chaos. I think this architecture principle is absolutely right.

Oh I kinda missed that :smile:

No, I would consider these to be a series of steps that should either all succeed or otherwise stop on the first error. So I would use a Result type for that and then it’s declarative enough.

I would then do an integration test for the happy path and the sad path that’s it.

I don’t see a need for mocking here.

I could hypothetically introduce logic bugs at this stage though, for instance by not calling certain steps in the right order. Maybe adding a few more integration steps would be sufficient, or maybe rethinking the core logic would be better

1 Like

In terms of “imperative shell, functional core”, the “shell” is hard to unit test, because it contains so many reads and writes to its environment. There’s a diminishing return here, where slower functional tests are hard to avoid, at least in my experience. Or even manual tests.

Edit, just saw that Benjamin already made this exact point. :slight_smile:

1 Like

I first heard about this principle years ago here:

https://www.destroyallsoftware.com/screencasts/catalog/functional-core-imperative-shell

It’s from 2012, and in Ruby (obviously my subject of interest at the time). I thought you’d may be interested in watching this video, as an extra reference point. The author talks about mocking, etc.

Side note, I can’t mention Gary Bernhardt without mentioning his “Wat” talk, one of the funniest talk ever :smile:

https://www.destroyallsoftware.com/talks/wat

1 Like