Create unit tests

It is good practice to add unit tests as you go along. Follow the procedures below to create tests and run them.

I. Start test file

To start your test file, follow these steps:

  1. Create a test directory in the shoppingcart directory

  2. Create a test.js file in the test directory

  3. Add import statements to the file to make use of Mocha and other libraries:

    // Imports
    const path = require("path");
    const should = require("chai").should();
    const grpc = require("grpc");
    const protoLoader = require("@grpc/proto-loader");
    const protobufHelper = require("cloudstate/src/protobuf-helper");
    const AnySupport = require("cloudstate/src/protobuf-any");
  4. Create a package definition of all protos to load and load a gRPC package definition as a gRPC object hierarchy with the below statements:

    const packageDefinition = protoLoader.loadSync(
      [
        path.join(__dirname, "..", "node_modules", "cloudstate", "proto", "cloudstate", "entity.proto"),
        path.join(__dirname, "..", "node_modules", "cloudstate", "proto", "cloudstate", "event_sourced.proto")
      ], {}
    );
    
    const descriptor = grpc.loadPackageDefinition(packageDefinition);
  5. Create an array of all the directories that have proto files that need to be included

    const allIncludeDirs = protobufHelper.moduleIncludeDirs.concat([
      path.join(__dirname, "..", "node_modules", "cloudstate", "proto")
    ]);
    
    const root = protobufHelper.loadSync([path.join(__dirname, "..", "cart.proto")], allIncludeDirs);
    const anySupport = new AnySupport(root)
  6. Start the gRPC server and specify variables for objects that are shared across functions:

    const shoppingCartEntity = require("../shoppingcart.js");
    const CloudState = require("cloudstate").CloudState;
    const server = new CloudState();
    server.addEntity(shoppingCartEntity);
    
    // the eventSourcedClient lets the tests in this file connect to the gRPC server
    let eventSourcedClient;
    
    // all commands that are sent to the gRPC server have an ID that the client can use to check for which command it got a reply
    let commandId = 0;
    
    // The userEntityID is the unique entity ID for all calls in this test
    const userEntityID = "123"
  7. Create a call object and initialize the server with a snapshot of data

    function callAndInit(snapshot) {
      const call = eventSourcedClient.handle();
      call.write({
        init: {
          // This is the name of the package and service in your proto file
          serviceName: "com.example.shoppingcart.ShoppingCart",
          entityKey: userEntityID,
          snapshot: snapshot
        }
      });
      return call;
    }

II. Add helper functions

Your test needs to send events and data to the gRPC server.

  1. Add helper functions to manage messages:

    // nextMessage lets you execute another call if needed
    function nextMessage(call) {
      let done;
      return new Promise((resolve, reject) => {
        call.on("data", msg => {
          done = true;
          resolve(msg);
        });
        call.on("end", () => {
          if (!done) {
            reject("Stream finished before next data was received");
          }
        });
      });
    }
    
    // fullNameOf generates the complete name of the message (which contains the name of the package and the name of the message)
    function fullNameOf(descriptor) {
      function namespace(desc) {
        if (desc.name === "") {
          return "";
        } else {
          return namespace(desc.parent) + desc.name + ".";
        }
      }
      return namespace(descriptor.parent) + descriptor.name;
    }
    
    // sendCommand sends a message to the gRPC server
    function sendCommand(call, name, payload) {
      const cid = ++commandId;
      call.write({
        command: {
          id: cid,
          name: name,
          payload: {
            value: payload.constructor.encode(payload).finish(),
            url: "type.googleapis.com/" + fullNameOf(payload.constructor.$type)
          }
        }
      });
      return nextMessage(call).then(msg => {
        should.exist(msg.reply);
        msg.reply.commandId.toNumber().should.equal(cid);
        should.exist(msg.reply.clientAction.reply);
        msg.reply.decodedPayload = anySupport.deserialize(msg.reply.clientAction.reply.payload);
        if (msg.reply.events) {
          msg.reply.decodedEvents = msg.reply.events.map(event => {
            return anySupport.deserialize(event);
          });
        }
        return msg.reply;
      });
    }
    
    // sendEvent sends an event to the gRPC server
    function sendEvent(call, sequence, event) {
      call.write({
        "event": {
          sequence: sequence,
          payload: AnySupport.serialize(event, false, true, true)
        }
      })
    }

III. Add functions to manage the cart

The functions to manage the cart correspond to the Protofuf definitions and the remote procedure calls you implemented.

  1. Add a function getCartByUserID that finds the cart associated with the userID that is passed in:

    function getCartByUserID(call, userID) {
      return sendCommand(call, "GetCart", root.lookupType("com.example.shoppingcart.GetShoppingCart").create({
        userId: userID
      }));
    }
  2. Add a function addItem that adds an item to the shopping cart of the user (the userID is embedded in the item itself):

    function addItem(call, item) {
      return sendCommand(call, "AddItem", root.lookupType("com.example.shoppingcart.AddLineItem").create(item));
    }
  3. Add a function removeItem that removes an item from the shopping cart of the user (the userID is embedded in the item itself):

    function removeItem(call, item) {
      return sendCommand(call, "RemoveItem", root.lookupType("com.example.shoppingcart.RemoveLineItem").create(item));
    }

IV. Add unit tests

You will add a before function that starts a new instance of the Shopping Cart server and connects to it using a gRPC connection. Since it’s on your local machine, where you do not have ssl certificates, it creates an insecure connection. The after function stops the gRPC server when all tests are completed.

  1. Add a describe function that contains the unit tests for the shopping cart.

    describe("Shopping Cart UnitTests", () => {
    
      before("Start a Shopping Cart server", () => {
        const port = server.start({
          bindPort: 0
        });
        eventSourcedClient = new descriptor.cloudstate.eventsourced.EventSourced("127.0.0.1:" + port, grpc.credentials.createInsecure());
      });
    
      after("Stop Shopping Cart server", () => {
        server.shutdown();
      });
    
      // Add tests here
    
    });
  2. In the describe function add a test that validates a connection can be made, a new cart can be created, and no no previous state exists:

      it("should respond to commands", () => {
        const call = callAndInit();
        return getCartByUserID(call, userEntityID)
          .then(reply => {
            should.not.exist(reply.events);
            should.not.exist(reply.snapshot);
            reply.decodedPayload.items.should.be.empty;
            call.end();
          });
      });
  3. In the describe function add a test that validates an item can be added as an event and that the state is kept correctly (this tests the eventHandlers):

      it("should accept events", () => {
        const call = callAndInit();
        sendEvent(call, 1, {
          type: "ItemAdded",
          item: {
            productId: "abc",
            name: "Some product",
            quantity: 10
          }
        });
        return getCartByUserID(call, userEntityID)
          .then(reply => {
            reply.decodedPayload.items[0].should.include({ productId: "abc", name: "Some product", quantity: 10 });
            call.end();
          });
      });
  4. In the describe function add a test that validates an item can be added as an command and that the state is kept correctly (this tests the commandHandlers):

      it("should emit events", () => {
        const call = callAndInit();
        return addItem(call, {
          userId: userEntityID,
          productId: "def",
          name: "Another product",
          quantity: 11
        }).then(reply => {
          reply.events.should.have.lengthOf(1);
          reply.events[0].type_url.should.equal("json.cloudstate.io/ItemAdded");
          reply.decodedEvents[0].item.should.include({ productId: "def", name: "Another product", quantity: 11 });
          return getCartByUserID(call, userEntityID);
        }).then(reply => {
          reply.decodedPayload.items[0].should.include({ productId: "def", name: "Another product", quantity: 11 });
          call.end();
        });
      });
  5. In the describe function add a test that validates an item can be deleted as an command and that the state is kept correctly (this tests the commandHandlers):

      it("should handle deletions", () => {
        const call = callAndInit();
        return addItem(call, {
          userId: userEntityID,
          productId: "def",
          name: "Another product",
          quantity: 11
        }).then(() => {
          return removeItem(call, {
            userId: userEntityID,
            productId: "def",
          })
        }).then(() => {
          return getCartByUserID(call, userEntityID);
        }).then(reply => {
          reply.decodedPayload.items.should.be.empty
          call.end();
        });
      });

The complete test.js file

Your complete test.js file should look like the following:

// Imports
const path = require("path");
const should = require("chai").should();
const grpc = require("grpc");
const protoLoader = require("@grpc/proto-loader");
const protobufHelper = require("cloudstate/src/protobuf-helper");
const AnySupport = require("cloudstate/src/protobuf-any");

const packageDefinition = protoLoader.loadSync(
    [
        path.join(__dirname, "..", "node_modules", "cloudstate", "proto", "cloudstate", "entity.proto"),
        path.join(__dirname, "..", "node_modules", "cloudstate", "proto", "cloudstate", "event_sourced.proto")
    ], {}
);

const descriptor = grpc.loadPackageDefinition(packageDefinition);

const allIncludeDirs = protobufHelper.moduleIncludeDirs.concat([
    path.join(__dirname, "..", "node_modules", "cloudstate", "proto")
]);

const root = protobufHelper.loadSync([path.join(__dirname, "..", "cart.proto")], allIncludeDirs);
const anySupport = new AnySupport(root)

const shoppingCartEntity = require("../shoppingcart.js");
const CloudState = require("cloudstate").CloudState;
const server = new CloudState();
server.addEntity(shoppingCartEntity);

// the eventSourcedClient lets the tests in this file connect to the gRPC server
let eventSourcedClient;

// all commands that are sent to the gRPC server have an ID that the client can use to check for which command it got a reply
let commandId = 0;

// The userEntityID is the unique entity ID for all calls in this test
const userEntityID = "123"

function callAndInit(snapshot) {
    const call = eventSourcedClient.handle();
    call.write({
        init: {
            // This is the name of the package and service in your proto file
            serviceName: "com.example.shoppingcart.ShoppingCart",
            entityKey: userEntityID,
            snapshot: snapshot
        }
    });
    return call;
}

// nextMessage lets you execute another call if needed
function nextMessage(call) {
    let done;
    return new Promise((resolve, reject) => {
        call.on("data", msg => {
            done = true;
            resolve(msg);
        });
        call.on("end", () => {
            if (!done) {
                reject("Stream finished before next data was received");
            }
        });
    });
}

// fullNameOf generates the complete name of the message (which contains the name of the package and the name of the message)
function fullNameOf(descriptor) {
    function namespace(desc) {
        if (desc.name === "") {
            return "";
        } else {
            return namespace(desc.parent) + desc.name + ".";
        }
    }
    return namespace(descriptor.parent) + descriptor.name;
}

// sendCommand sends a message to the gRPC server
function sendCommand(call, name, payload) {
    const cid = ++commandId;
    call.write({
        command: {
            id: cid,
            name: name,
            payload: {
                value: payload.constructor.encode(payload).finish(),
                url: "type.googleapis.com/" + fullNameOf(payload.constructor.$type)
            }
        }
    });
    return nextMessage(call).then(msg => {
        should.exist(msg.reply);
        msg.reply.commandId.toNumber().should.equal(cid);
        should.exist(msg.reply.clientAction.reply);
        msg.reply.decodedPayload = anySupport.deserialize(msg.reply.clientAction.reply.payload);
        if (msg.reply.events) {
            msg.reply.decodedEvents = msg.reply.events.map(event => {
                return anySupport.deserialize(event);
            });
        }
        return msg.reply;
    });
}

// sendEvent sends an event to the gRPC server
function sendEvent(call, sequence, event) {
    call.write({
        "event": {
            sequence: sequence,
            payload: AnySupport.serialize(event, false, true, true)
        }
    })
}

function getCartByUserID(call, userID) {
    return sendCommand(call, "GetCart", root.lookupType("com.example.shoppingcart.GetShoppingCart").create({
        userId: userID
    }));
}

function addItem(call, item) {
    return sendCommand(call, "AddItem", root.lookupType("com.example.shoppingcart.AddLineItem").create(item));
}

function removeItem(call, item) {
    return sendCommand(call, "RemoveItem", root.lookupType("com.example.shoppingcart.RemoveLineItem").create(item));
}

describe("Shopping Cart UnitTests", () => {

    before("Start a Shopping Cart server", () => {
        const port = server.start({
            bindPort: 0
        });
        eventSourcedClient = new descriptor.cloudstate.eventsourced.EventSourced("127.0.0.1:" + port, grpc.credentials.createInsecure());
    });

    after("Stop Shopping Cart server", () => {
        server.shutdown();
    });

    // Add tests here
    it("should respond to commands", () => {
        const call = callAndInit();
        return getCartByUserID(call, userEntityID)
            .then(reply => {
                should.not.exist(reply.events);
                should.not.exist(reply.snapshot);
                reply.decodedPayload.items.should.be.empty;
                call.end();
            });
    });

    it("should accept events", () => {
        const call = callAndInit();
        sendEvent(call, 1, {
            type: "ItemAdded",
            item: {
                productId: "abc",
                name: "Some product",
                quantity: 10
            }
        });
        return getCartByUserID(call, userEntityID)
            .then(reply => {
                reply.decodedPayload.items[0].should.include({ productId: "abc", name: "Some product", quantity: 10 });
                call.end();
            });
    });

    it("should emit events", () => {
        const call = callAndInit();
        return addItem(call, {
            userId: userEntityID,
            productId: "def",
            name: "Another product",
            quantity: 11
        }).then(reply => {
            reply.events.should.have.lengthOf(1);
            reply.events[0].type_url.should.equal("json.cloudstate.io/ItemAdded");
            reply.decodedEvents[0].item.should.include({ productId: "def", name: "Another product", quantity: 11 });
            return getCartByUserID(call, userEntityID);
        }).then(reply => {
            reply.decodedPayload.items[0].should.include({ productId: "def", name: "Another product", quantity: 11 });
            call.end();
        });
    });

    it("should handle deletions", () => {
        const call = callAndInit();
        return addItem(call, {
            userId: userEntityID,
            productId: "def",
            name: "Another product",
            quantity: 11
        }).then(() => {
            return removeItem(call, {
                userId: userEntityID,
                productId: "def",
            })
        }).then(() => {
            return getCartByUserID(call, userEntityID);
        }).then(reply => {
            reply.decodedPayload.items.should.be.empty
            call.end();
        });
    });
});

V. Run tests

  1. To run the unit tests, open a terminal window and navigate to the shoppingcart directory. From there run npm test to see all tests pass:

    $ npm test
    
    > shoppingcart@1.0.0 pretest /home/retgits/Downloads/shoppingcart
    > compile-descriptor ./cart.proto
    
    Compiling descriptor with command: /home/retgits/Downloads/shoppingcart/node_modules/cloudstate/protoc/bin/protoc --include_imports --proto_path=/home/retgits/Downloads/shoppingcart/node_modules/cloudstate/proto --proto_path=/home/retgits/Downloads/shoppingcart/node_modules/cloudstate/protoc/include --descriptor_set_out=user-function.desc --proto_path=. ./cart.proto
    
    > shoppingcart@1.0.0 test /home/retgits/Downloads/shoppingcart
    > mocha
    
    
    
      Shopping Cart UnitTests
    gRPC server started on 0.0.0.0:35167
    getCart { items: [] }
        ✓ should respond to commands
    itemAdded
    itemAdded::push
    return state
    getCart { items: [ { name: 'Some product', productId: 'abc', quantity: 10 } ] }
        ✓ should accept events
    addItem AddLineItem {
      userId: '123',
      productId: 'def',
      name: 'Another product',
      quantity: 11
    }
    addItem::emit event {
      type: 'ItemAdded',
      item: { productId: 'def', name: 'Another product', quantity: 11 }
    }
    itemAdded
    itemAdded::push
    return state
    getCart {
      items: [ { name: 'Another product', productId: 'def', quantity: 11 } ]
    }
        ✓ should emit events
    addItem AddLineItem {
      userId: '123',
      productId: 'def',
      name: 'Another product',
      quantity: 11
    }
    addItem::emit event {
      type: 'ItemAdded',
      item: { productId: 'def', name: 'Another product', quantity: 11 }
    }
    itemAdded
    itemAdded::push
    return state
    removeItem RemoveLineItem { userId: '123', productId: 'def' }
    removeItem:: return existing
    getCart { items: [] }
        ✓ should handle deletions
    
    gRPC server has shutdown.
    
      4 passing (64ms)