Cosmos DB Server-Side Programming with TypeScript - Part 5: Unit Testing
Over the last four parts of this series, we've discussed how we can write server-side code for Cosmos DB, and the types of situations where it makes sense to do so. If you're building a small sample application, you now have enough knowledge to go and build out UDFs, stored procedures, and triggers. But if you're writing production-grade applications, there are two other major topics that need discussion: how to unit test your server-side code, and how to build and deploy it to Cosmos DB in an automated and predictable manner. In this part, we'll discuss testing. In the next part, we'll discuss build and deployment.
This post is part of a series:
Part 1 gives an overview of the server side programmability model, the reasons why you might want to consider server-side code in Cosmos DB, and some key things to watch out for.
Part 2 deals with user-defined functions, the simplest type of server-side programming, which allow for adding simple computation to queries.
Part 3 talks about stored procedures. These provide a lot of powerful features for creating, modifying, deleting, and querying across documents - including in a transactional way.
Part 4 introduces triggers. Triggers come in two types - pre-triggers and post-triggers - and allow for behaviour like validating and modifying documents as they are inserted or updated, and creating secondary effects as a result of changes to documents in a collection.
Part 5 (this post) discusses unit testing your server-side scripts. Unit testing is a key part of building a production-grade application, and even though some of your code runs inside Cosmos DB, your business logic can still be tested.
Finally, part 6 explains how server-side scripts can be built and deployed into a Cosmos DB collection within an automated build and release pipeline, using Microsoft Visual Studio Team Services (VSTS).
Unit Testing Cosmos DB Server-Side Code
Mocks: mocking allows us to pass in mocked versions of our dependencies so that we can test how our code behaves independently of a working external system. In the case of Cosmos DB, this is very important: the
getContext()method, which we've looked at throughout this series, provides us with access to objects that represent the request, response, and collection. Our code needs to be tested without actually running inside Cosmos DB, so we mock out the objects it sends us.
Spies: spies are often a special type of mock. They allow us to inspect the calls that have been made to the object to ensure that we are triggering the methods and side-effects that we expect.
Type safety: as in the rest of this series, it's important to use strongly typed objects where possible so that we get the full benefit of the TypeScript compiler's type system.
I will assume some familiarity with these concepts, but even if they're new to you, you should be able to follow along. Also, please note that this series only deals with unit testing. Integration testing your server-side code is another topic, although it should be relatively straightforward to write integration tests against a Cosmos DB server-side script.
Challenges of Testing Cosmos DB Server-Side Code
Defining Our Tests
We'll be working with the stored procedure that we built out in part 3 of this series. The same concepts could be applied to unit testing triggers, and also to user-defined functions (UDFs) - and UDFs are generally easier to test as they don't have any context variables to mock out.
Looking back at the stored procedure, the purpose is to do return the list of customers who have ordered any of specified list of product IDs, grouped by product ID, and so an initial set of test cases might be as follows:
productIdsparameter is empty, the method should return an empty array.
productIdsparameter contains one item, it should execute a query against the collection containing the item's identifier as a parameter.
productIdsparameter contains one item, the method should return a single
CustomersGroupedByProductobject in the output array, which should contain the
productIdthat was passed in, and whatever
customerIdsthe mocked collection query returned.
If the method is called with a valid
productIdsarray, and the
queryDocumentsmethod on the collection returns
false, an error should be returned by the function.
You might have others you want to focus on, and you may want to split some of these out - but for now we'll work with these so we can see how things work. Also, in this post I'll assume that you've got a copy of the stored procedure from part 3 ready to go - if you haven't, you can download it from the GitHub repository for that part.
If you want to see the finished version of the whole project, including the tests, you can access it on GitHub here.
Setting up TypeScript Configurations
The first change we'll need to make is to change our TypeScript configuration around a bit. Currently we only have one
tsconfig.json file that we use to build. Now we'll need to add a second file. The two files will be used for different situations:
tsconfig.jsonwill be the one we use for local builds, and for running unit tests.
tsconfig.build.jsonwill be the one we use for creating release builds.
First, open up the
tsconfig.json file that we already have in the repository. We need to change it to the following:
The key changes we're making are:
We're now including files from the
specfolder in our build. This folder will contain the tests that we'll be writing shortly.
We've added the line
"module": "commonjs". This tells TypeScript that we want to compile our code with module support. Again, this
tsconfig.jsonwill only be used when we run our builds locally or for running tests, so we'll later make sure that the module-related code doesn't make its way into our release builds.
We've changed from using
outDir, and set the output directory to
output/test. When we use modules like we're doing here, we can't use the
outFilesetting to combine our files together, but this won't matter for our local builds and for testing. We also put the output files into a
testsubfolder of the
outputfolder so that we keep things organised.
Now we need to create a
tsconfig.build.json file with the following contents:
This looks more like the original
tsconfig.json file we had, but there are a few minor differences:
includeelement now looks for files matching the pattern
*.ready.ts. We'll look at what this means later.
modulesetting is explicitly set to
none. As we'll see later, this isn't sufficient to get everything we need, but it's good to be explicit here for clarity.
outFilesetting - which we can use here because
moduleis set to
buildsubfolder of the
Now let's add the testing framework.
Adding a Testing Framework
package.json file and replace it with this:
There are a few changes to our previous version:
We've now imported the
jasminemodule, as well as the Jasmine type definitions, into our project; and we've imported
moq.ts, a mocking library, which we'll discuss below.
We've also added a new
testscript, which will run a build and then execute Jasmine, passing in a configuration file that we will create shortly.
npm install from a command line/terminal to restore the packages, and then create a new file named
jasmine.json with the following contents:
We'll understand a little more about this file as we go on, but for now, we just need to understand that this file defines the Jasmine specification files that we'll be testing against. Now let's add our Jasmine test specification so we can see this in action.
Starting Our Test Specification
Let's start by writing a simple test. Create a folder named
spec, and within it, create a file named
getGroupedOrdersImpl.spec.ts. Add the following code to it:
This code does the following:
It sets up a new Jasmine spec named
getGroupedOrdersImpl. This is the name of the method we're testing for clarity, but it doesn't need to match - you could name the spec whatever you want.
Within that spec, we have a test case named
should return an empty array.
That test executes the
getGroupedOrdersImplfunction, passing in an empty array, and a null object to represent the
Then the test confirms that the result of that function call is an empty array.
This is a fairly simple test - we'll see a slightly more complex one in a moment. For now, though, let's get this running.
There's one step we need to do before we can execute our test. If we tried to run it now, Jasmine would complain that it can't find the
Open up the
src/getGroupedOrders.ts file, and add the following at the very bottom of the file:
export statement sets up the necessary TypeScript compilation instruction to allow our Jasmine test spec to reach this method.
Now let's run our test. Execute
npm run test, which will compile our stored procedure (including the export), compile the test file, and then execute Jasmine. You should see that Jasmine executes the test and shows
1 spec, 0 failures, indicating that our test successfully ran and passed. Now let's add some more sophisticated tests.
Adding Tests with Mocks and Spies
When we're testing code that interacts with external services, we often will want to use mock objects to represent those external dependencies. Most mocking frameworks allow us to specify the behaviour of those mocks, so we can simulate various conditions and types of responses from the external system. Additionally, we can use spies to observe how our code calls the external system.
Jasmine provides a built-in mocking framework, including spy support. However, the Jasmine mocks don't support TypeScript types, and so we lose the benefit of type safety. In my opinion this is an important downside, and so instead we will use the moq.ts mocking framework. You'll see we have already installed it in the
Since we've already got it available to us, we need to add this line to the top of our
This tells TypeScript to import the relevant mocking types from the moq.ts module. Now we can use the mocks in our tests.
Let's set up another test, in the same file, as follows:
This test does a little more than the last one:
It sets up a mock of the
This mock will send back a hard-coded string (
self-link) when the
getSelfLink()method is called.
It also provides mock behaviour for the
queryDocumentsmethod. When the method is called, it invokes the
callbackfunction, passing back a list of documents with a single empty string, and then returns
trueto indicate that the query was accepted.
mock.object()method is used to convert the mock into an instance that can be provided to the
getGroupedOrderImplfunction, which then uses that in place of the real Cosmos DB collection. This means we can test out how our code will behave, and we can emulate the behaviour of Cosmos DB as we wish.
Finally, we call
mock.verifyto ensure that the
getGroupedOrdersImplfunction executed the
queryDocumentsmethod on the mock collection exactly once.
You can run
npm run test again now, and verify that it shows
2 specs, 0 failures, indicating that our new test has successfully passed.
Now let's fill out the rest of the spec file - here's the complete file with all of our test cases included:
You can execute the tests again by calling
npm run test. Try tweaking the tests so that they fail, then re-run them and see what happens.
Building and Running
All of the work we've just done means that we can run our tests. However, if we try to build our code to submit to Cosmos DB, it won't work anymore. This is because the
We can remove this code at build time by using a preprocessor. This will remove the
To achieve this, we need to chain together a few pieces. First, let's open up the
src/getGroupedOrders.ts file. Replace the line that says
export with this section:
The extra lines we've added are preprocessor directives. TypeScript itself doesn't understand these directives, so we need to use an NPM package to do this. The one I've used here is jspreproc. It will look through the file and handle the directives it finds in specially formatted comments, and then emits the resulting cleaned file. Unfortunately, the preprocessor only works on a single file at a time. This is OK for our situation, as we have all of our stored procedure code in one file, but we might not do that for every situation. Therefore, I have also used the foreach-cli NOM package to search for all of the
*.ts files within our
src folder and process them. It saves the cleaner files with a
.ready.ts extension, which our
tsconfig.build.json file refers to.
package.json file and replace it with the following contents:
Now we can run
npm install to install all of the packages we're using. You can then run
npm run test to run the Jasmine tests, and
output/build/sp-getGroupedOrders.js file, and if you inspect that file, you'll see it doesn't have any trace of module exports. It looks just like it did back in part 3, which means we can send it to Cosmos DB without any trouble.
In this post, we've built out the necessary infrastructure to test our Cosmos DB server-side code. We've used Jasmine to run our tests, and moq.ts to mock out the Cosmos DB server objects in a type-safe manner. We also adjusted our build script so that we can compile a clean copy of our stored procedure (or trigger, or UDF) while keeping the necessary
export statements to enable our tests to work. In the final post of this series, we'll look at how we can automate the build and deployment of our server-side code using VSTS, and integrate it into a continuous integration and continuous deployment pipeline.
It's important to test Cosmos DB server-side code. Stored procedures, triggers, and UDFs contain business logic and should be treated as a fully fledged part of our application code, with the same quality criteria we would apply to other types of source code.
We can use Jasmine for testing. Jasmine also has a mocking framework, but it is not strongly typed.
We can get strong typing using a TypeScript mocking library like moq.ts.
By structuring our code correctly - using a single entry-point function, which calls out to
getContext()and then sends the necessary objects into a function that implements our actual logic - we can easily mock and spy on our calls to the Cosmos DB server-side libraries.
We need to export the functions we are testing using the
exportstatement. This makes them available to the Jasmine test spec.
exportstatements need to be removed before we can compile our release version. We can use a preprocessor to remove those statements.