unit-testingfunctional-programming

Approach to functional core, imperative shell


I saw several people talking about functional core and imperative shell and how it relates to unit tests, avoiding mocks, etc... However, I can't see refactoring situations in cases with little domain logic and more side effects. What would be a good approach to refactor and unit test a scenario like this:

function createOrder(userInfo, addressInfo, orderInfo) {
    const userExists = this.userApi.findUser(userInfo.email);
    if(!userExists) this.userApi.createUser(userInfo);
    else this.userApi.updateUser(userInfo);

    const adressExists = this.userAdressApi.findAdress(addressInfo);
    if(!adressExists) this.userAdressApi.createUserAdress(userInfo.email, addressInfo);
    else this.userAdressApi.updateUserAdress(userInfo.email, userAdressInfo);

    this.orderApi.create(orderInfo);
}

Solution

  • The reason you aren't seeing the point of FCIS is because you are hiding a lot of the logic this function needs. If we break down what this method is actually doing, it can be made more clear so let's do that:

    function createOrder(userInfo, addressInfo, orderInfo) {
    

    First you are calling findUser in the userApi. This function does the following:

    1. creates a DB command using the userInfo.email value
    2. queries the DB with the command just created
    3. converts the response into a boolean object

    Then you take the boolean from above and call either createUserAdress or updateUserAdress again, this means you are:

    1. using the boolean from above and the userInfo value to create another DB command.
    2. sending the command to the DB

    Let's pause there and see what we would have using FCIS...

    1. a pure function that takes an email and returns a DBCommand.
    2. a pure function that takes the response from the above command and a userInfo and returns a DBCommand.
    3. an impure function that takes a DBCommand, sends it to the DB and optionally returns a value.

    So if developed with FCIS in mind, you would have:

    const findUser = createFindUser(userInfo.email);
    const userExists = this.api.perform(findUser);
    const upsertUser = createUpsert(userExists, userInfo);
    this.api.perform(upsertUser);
    

    And we would write tests for both create functions, neither of which would need a mock to test. (BTW, there is no assumption here that userExists is a bool; the only assumption is that it's something the createUpsert command can use.)

    The same process would hold for the user's address, and the order creation.

    In the final code then, we would have five pure functions, each of which is fully testable without mocks, and a single impure function on a single api object. Like this:

    function createOrder(userInfo, addressInfo, orderInfo) {
        const findUser = createFindUser(userInfo.email);
        const userExists = this.api.perform(findUser);
        const upsertUser = createUpsert(userExists, userInfo);
        this.api.perform(upsertUser);
    
        // I went with a more compact expression for the below,
        //   but it's the same structure.
        const addressExists = this.api.perform(createFindAddress(addressInfo));
        this.api.perform(createUpsertAddress(userInfo.email, addressExists, addressInfo));
        
        this.api.perform(createOrder(orderInfo));
    }
    

    So instead of a UserAPI class with three methods all of which need to be tested using mocks and an AddressAPI class with three methods (again, all of which need to be tested using mocks), and an OrderAPI class with a method (again needing a mock to test), and lastly our principle function that needs three mocks to test... you only will be testing five functions, none of which need mocks.

    Would you bother testing the principle function in this case? Likely not; after all it doesn't have any logic in it. It's just straight line code. But if you did choose to test it, you would only need a single mock instead of three.

    Functional Core, Imperative Shell is a clear winner for both the simplicity of the needed tests and a reduction in the number of tests needed.