14

How to Test Services

We’ve been building up to this point where we can use both the testing library and the logging library in another project. The customer of the logging library has always been a microservices C++ developer who is using TDD to design better services.

Because of the focus on services, this chapter will introduce a project that simulates a microservice. We’re not going to include everything that a real service would need. For example, a real service needs networking and the ability to route and queue requests and handle timeouts. Our service will only contain the core methods to start the service and handle requests.

You’ll learn about the challenges involved with testing services and how testing a service is different from testing an application that tries to do everything. There will be less focus in this chapter on the design of the service. And we’re not going to be writing all the tests needed. In fact, this entire chapter only uses a single test. Other tests are mentioned that can be added.

We’ll also explore what can be tested in a service, along with some tips and guidance that will enable you to control the amount of logging that gets generated when debugging a service.

The service project will help tie together the testing and logging libraries and show you how to use both libraries in your own projects.

The main topics in this chapter are as follows:

  • Service testing challenges
  • What can be tested in a service?
  • Introducing the SimpleService project

Technical requirements

All code in this chapter uses standard C++ that builds on any modern C++ 20 or later compiler and standard library. The code introduces a new service project that uses the testing library from Part 1, Testing MVP, of this book, and uses the logging library from Part 2, Logging Library, of this book.

You can find all the code for this chapter in the following GitHub repository:

https://github.com/PacktPublishing/Test-Driven-Development-with-CPP

Service testing challenges

The customer we’ve been thinking about throughout this book has been a microservices developer writing services in C++ who wants to better understand TDD to improve the development process and increase the code quality. TDD is for anybody writing code. But in order to follow TDD, you need to have a clear idea of who your customer is so that you can write tests from that customer’s viewpoint.

There are different challenges involved when testing services as compared to testing an application that does everything itself. An application that includes everything is often called a monolithic application. Some examples of challenges that apply to services are these:

  • Is the service reachable?
  • Is the service running?
  • Is the service overloaded with other requests?
  • Are there any permissions or security checks that could affect your ability to call a service?

Before we get too far though, we need to understand what a service is and why you should care.

A service runs on its own and receives requests, processes the requests, and returns some type of response for each request. A service is focused on the requests and responses, which makes them easier to write and debug. You don’t have to worry about other code interacting with your service in unexpected ways because the request and response fully define the interaction. If your service starts getting too many requests, you can always add more instances of the service to handle the extra load. When services are focused on handling a few specific requests, they’re called microservices. Building large and complicated solutions becomes easier and more reliable when you can divide the work into microservices.

Services can also make requests to other services in order to process a request. This is how microservices can build on each other to form bigger services. At each step, the request and expected response are clear and well defined. Maybe your entire solution is composed entirely of services. But more likely, you’ll have an application that a customer runs, which accepts the customer’s input and direction and makes requests from various services to fulfill the customer’s need. Maybe the customer opens an application window that displays a chart of information based on some dates that the customer provides. In order to get the data to display the chart, the application will send the dates in a request to a service that will respond with the data. The service might even customize the data based on the specific customer making the request.

Imagine how much harder it would be to write an application that tried to do everything itself. The development effort might even go from an unthinkably complicated monolithic application to a reasonable effort when using services. The quality also goes up when tasks can be isolated and developed, and managed independently as services.

Services typically run on multiple computers, so the requests and responses are made over a network. There could be other routing code involved too that accepts a request and puts it in a queue before sending the request to a service. A service might be running on multiple computers, and the router will figure out which service is best able to process a request.

If you’re lucky enough to have a large and well-designed network of services, then you’ll probably have multiple separate networks designed to help you test and deploy your services. Each network can have many different computers, and each computer can be running multiple different services. This is where routers become very useful.

Testing a service that’s running in multiple networks typically involves deploying a new version of the service to be tested on a computer in one of the networks designed for early testing. This network is usually called a development environment.

If the tests fail in the development environment, then you have time to find the bugs, make changes, and test a new version, until the service runs as expected. Finding bugs involves looking at the responses to make sure they are correct, examining the log files to make sure the correct steps were taken along the way, and looking at any other output, such as database entries that might have been modified while processing a request. Depending on the service, you might have other things to check.

Some services depend on data stored in databases to properly respond to requests. It might be difficult to keep the databases current in a development environment, which is why other environments are usually needed. If the initial tests pass in the development environment, then you might deploy the service changes to a beta environment and test again. Eventually, you’ll deploy the service to the production environment where it will serve responses for customers.

If you can control the routing of requests, then it might be possible for you to run a debugger when testing your changes. The way to do this is to start the service on a particular computer under the debugger. Usually, this will only be done in the development environment. Then, you will need to make sure that any requests you make through a test user account get routed to the computer where you have the debugger running. The same service without your recent changes will likely be running on other computers in the same environment, which is why debugging with a debugger only works if you can make sure that the requests will be routed to the computer you’re using.

If you don’t have the ability to route requests to a specific computer or if you’re testing in an environment that doesn’t allow debuggers, then you’ll need to rely heavily on the log messages. Sometimes you won’t know ahead of time which computer in an environment will handle a request, so you’ll need to deploy your service to all the computers in that environment.

Examining log files can be tedious because you need to visit each computer just to open the log files to see if your testing request was handled on that computer or some other computer. If you have a service that gathers log files from each computer and makes the log messages available for searching, then you’ll have a much easier time testing your service in environments that have multiple computers.

You don’t have the same distributed testing problems when testing a single application that doesn’t use services. You can even use your own computer for much of the testing. You can run your changes under a debugger, examine log files, and run unit tests quickly and directly. Services require much more support, such as a message routing infrastructure that you might not be able to set up on your own computer.

Every company and organization that builds solutions with microservices will have different environments and deployment steps. There’s no way that I can tell you how to test your particular service. And that’s not the goal of this section. I’m only explaining the challenges with testing services that are different from testing an application that tries to do everything.

Even with all the extra networking and routing, services are a great way to design a large application. Who knows, the routing might even be a service itself. With all the isolated and independent services, it becomes possible to add new features and upgrade the user experience in small steps instead of releasing a new version that does everything.

Using services for a small application might not be worth the overhead. But I’ve seen a lot of small applications that grew into large applications and then got stuck when the complexity became too much. And the same thing happens with services and the language they are written in. I’ve seen services start out so small they could be written in a few lines of Python code. The developers might have been under a tight deadline and writing a small service in Python was faster than writing the same service in C++. Eventually, the small service proves to be valuable to other teams and grows in usage and in features. And it continues to grow until it needs to be replaced by a service written in C++.

Now that you know a bit more about the challenges of testing services, the next section explores what can be tested.

What can be tested in a service?

Services are defined by the requests accepted and the responses returned. A service will also have an address or some means of routing the requests to the service. There could be a version number or maybe the version is included as part of the address.

When putting all this together, you first need to prepare a request and send the request to the service. The response might come all at once or in pieces. Or maybe the response is like a ticket that you can present at a later time to the same or a different service to get the actual response.

All this means that there are different ways to interact with a service. About the only thing that remains the same is the basic idea of a request and then a response. If you make a request, there’s no guarantee that a service will receive the request. And if a service replies, there’s no guarantee that the response will make it back to the original requestor. Handling timeouts is always a big concern when working with services.

You probably won’t want to test for timeouts directly because it can easily be anywhere from 30 seconds to 5 minutes before a service request is aborted due to no response. But you might want to test for response times within an expected and reasonable time. Be careful with tests like this though because they can sometimes pass and sometimes fail depending on many factors that can change and are outside of the direct control of the test. A timeout test is also more of a stress test or an acceptance test, and while it might help identify a poor design after the service has been deployed, focusing on timeouts initially is usually the wrong choice for TDD.

Instead, treat a service just like any other software that you’ll be designing with TDD. Be clear about who the customer is and what their needs are, and then come up with a request and a response that makes the most sense, is easy to use, and is easy to understand.

When testing a service, it might be enough to make sure that the response contains the correct information. This will likely be the case for services that are completely out of your control. But it can be useful for a service to remain completely detached from any calling code and only interact through the request and the response.

Maybe you’re calling a service that was created by a different company and the response is the only way to get the information requested. If so, then why are you testing the service? Remember to only test your code.

Assuming this is your service that you’re designing and testing and that the response fully contains the information requested, then you can write tests that only need to form a request and examine the response.

Other times, a request might result in a response that simply acknowledges the request. The actual results of the request can appear elsewhere. In this case, you’ll want to write tests that form a request, verify the response, and then verify the actual results wherever they are. Let’s say you’re designing a service that lets callers request that a file be deleted. The request would contain information about the file. The response might just be an acknowledgment that the file was deleted. And the test might then need to look in the folder where the file used to be located to make sure the file is no longer available.

Usually, requests that ask a service to do something will result in the need to verify that the action really was performed. And requests that ask a service to calculate something or return something might be able to confirm the information directly in the response. If the requested information is really big, then it might be better to find a different way to return the information.

However you design your service, the main point is that there are many options. You’ll want to consider how your service will be used when writing your tests to create the design.

You might even need to call two or more services in your tests. For example, if you’re writing a service that’s designed to replace an older service with a slow calculation response time, you might want to call both services and compare the returned information to make sure that the new service is still returning the same information as the older service.

Services have a lot of overhead involved with the formatting and routing of the request, and the interpretation of the response. Testing a service is not like simply calling a function.

However, at some point internally, a service will contain a function to process or handle the request. This function is usually not exposed to users of the service. The users must go through the service interface, which involves routing a request and a response through a network connection.

Because C++ doesn’t yet have standard networking capabilities, which might arrive in C++23, we’re going to skip over all the networking and official request and response definitions. We’ll create a simple service that resembles what a real service would look like internally.

We’ll also focus on the type of service request that can return information completely in the response. The next section will introduce the service.

Introducing the SimpleService project

We’re going to start a new project in this section to build a service. And just like how the logging project uses the testing project, this service project will use the testing project. The service will go further and also use the logging project. The service won’t be a real service because a full service needs a lot of supporting code that is not standard C++ and would take us into topics unrelated to learning TDD.

The service will be called SimpleService and the initial set of files will tie together many of the topics already explained in this book. Here is the project structure:

SimpleService project root folder
    MereTDD folder
        Test.h
    MereMemo folder
        Log.h
    SimpleService folder
        tests folder
            main.cpp
            Message.cpp
            SetupTeardown.cpp
            SetupTeardown.h
        LogTags.h
        Service.cpp
        Service.h

When I started this project, I didn’t know what files would be needed. I knew the project would use MereTDD and MereMemo and would have its own folder for the service. Inside the SimpleService folder, I knew there would be a tests folder that would contain main.cpp. I guessed there would be Service.h and Service.cpp too. I also added a file for the first test called Message.cpp. The idea of the first test would be something that would send a request and receive a response.

So let’s start with the files that I knew would be in the project. Test.h and Log.h are the same files we’ve been developing so far in this book, and the main.cpp file looks similar, as follows:

#include <MereTDD/Test.h>
#include <iostream>
int main ()
{
    return MereTDD::runTests(std::cout);
}

The main.cpp file is actually a bit simpler than before. We’re not using any default log tags so there’s no need to include anything about logging. We just need to include the testing library and run the tests.

The first test that I wrote went in Message.cpp and looked like this:

#include "../Service.h"
#include <MereTDD/Test.h>
using namespace MereTDD;
TEST("Request can be sent and response received")
{
    std::string user = "123";
    std::string path = "";
    std::string request = "Hello";
    std::string expectedResponse = "Hi, " + user;
    SimpleService::Service service;
    service.start();
    std::string response = service.handleRequest(
        user, path, request);
    CONFIRM_THAT(response, Equals(expectedResponse));
}

My thinking at the time was there would be a class called Service that could be constructed and started. Once the service was started, we could call a method called handleRequest, which would need a user ID, a service path, and the request. The handleRequest method would return the response, which would be a string.

The request would also be a string and I decided to go with a simple greeting service. The request would be the "Hello" string and the response would be "Hi, " followed by the user ID. I put a Hamcrest-style confirmation of the response in the test.

I realized that we would eventually need other tests, and the other tests should use a service that was already started. Reusing an already running service would be better than creating an instance of the service and starting the service each time a test is run. So, I changed the Message.cpp file to use a test suite with setup and teardown like this:

#include "../Service.h"
#include "SetupTeardown.h"
#include <MereTDD/Test.h>
using namespace MereTDD;
TEST_SUITE("Request can be sent and response received", "Service 1")
{
    std::string user = "123";
    std::string path = "";
    std::string request = "Hello";
    std::string expectedResponse = "Hi, " + user;
    std::string response = gService1.service().handleRequest(
        user, path, request);
    CONFIRM_THAT(response, Equals(expectedResponse));
}

This is the only test we’re going to add to the service in this chapter. It will be enough to send a request and get a response.

I added the SetupTeardown.h and SetupTeardown.cpp files to the tests folder. The header file looks like this:

#ifndef SIMPLESERVICE_TESTS_SUITES_H
#define SIMPLESERVICE_TESTS_SUITES_H
#include "../Service.h"
#include <MereMemo/Log.h>
#include <MereTDD/Test.h>
class ServiceSetup
{
public:
    void setup ()
    {
        mService.start();
    }
    void teardown ()
    {
    }
    SimpleService::Service & service ()
    {
        return mService;
    }
private:
    SimpleService::Service mService;
};
extern MereTDD::TestSuiteSetupAndTeardown<ServiceSetup>
gService1;
#endif // SIMPLESERVICE_TESTS_SUITES_H

This file contains nothing you haven’t seen already in this book. Except that we previously declared setup and teardown classes in a single test .cpp file. This is the first time we’ve needed to declare setup and teardown in a header file so it can be reused in other test files later. You can see that the setup method calls the start method of the service. The only real difference is that the gService1 global instance needs to be declared extern, so we don’t get linker errors later with other test files also using the same setup and teardown code.

The SetupTeardown.cpp file looks like this:

#include "SetupTeardown.h"
MereTDD::TestSuiteSetupAndTeardown<ServiceSetup>
gService1("Greeting Service", "Service 1");

This is simply the instance of gService1 that was declared extern in the header file. The suite name "Service 1" needs to match the suite name used in the TEST_SUITE macro in Message.cpp.

Moving on to the Service class declaration in Service.h, it looks like this:

#ifndef SIMPLESERVICE_SERVICE_H
#define SIMPLESERVICE_SERVICE_H
#include <string>
namespace SimpleService
{
class Service
{
public:
    void start ();
    std::string handleRequest (std::string const & user,
        std::string const & path,
        std::string const & request);
};
} // namespace SimpleService
#endif // SIMPLESERVICE_SERVICE_H

I put the service code in the SimpleService namespace, which you saw in the original test and in the setup and teardown code. The start method needs no parameters and returns void. At least for now, anyway. We can always enhance the service later. I felt it was important to include the idea of starting a service from the very beginning, even if there’s not much to do yet. The idea that a service is already running and waiting to process requests is a core concept that defines what a service is.

The other method is the handleRequest method. We’re skipping over a lot of details of a real service, such as the definition of requests and responses. A real service would have a documented way to define requests and responses, almost like a programming language itself. We’re just going to use strings for both the request and the response.

A real service would use authentication and authorization to verify users and what each user is allowed to do with the service. We’re simply going to use a string as the user identity.

And some services have an idea called a service path. The path is not the address of the service. The path is like a call stack in programming terms. Usually, the router would start the path whenever an application makes a call to a service. The path parameter acts like a unique identifier for the call itself. If the service needs to call other services in order to process the request, then the router for these additional service requests would add to the initial path that was already started. Each time path grows, the router adds another unique identifier to the end of the path. The path can be used in the service to log messages.

The whole point of the path is so that developers can make sense of the log messages by relating and ordering log messages for specific requests. Remember that a service is handling requests all the time from different users. And calling other services will cause those other services to log their own activity. Having a path that identifies a single service request and all of its related service calls, even across multiple log files, is really helpful when debugging.

The implementation of the service is in Service.cpp and looks like this:

#include "Service.h"
#include "LogTags.h"
#include <MereMemo/Log.h>
void SimpleService::Service::start ()
{
    MereMemo::FileOutput appFile("logs");
    MereMemo::addLogOutput(appFile);
    MereMemo::log(info) << "Service is starting.";
}
std::string SimpleService::Service::handleRequest (
    std::string const & user,
    std::string const & path,
    std::string const & request)
{
    MereMemo::log(debug, User(user), LogPath(path))
        << "Received: " << Request(request);
    std::string response;
    if (request == "Hello")
    {
        response = "Hi, " + user;
    }
    else
    {
        response = "Unrecognized request.";
    }
    MereMemo::log(debug, User(user), LogPath(path))
        << "Sending: " << Response(response);
    return response;
}

Some books and guidance for TDD will say that this is too much code for a first test, that there should not be any logging or checking of the request string, and that, really, the first implementation should return an empty string just so that the test will fail.

Then the response should be hardcoded to be the exact value that the test expects. And then another test should be created that uses a different user ID. Only then should the response be built by looking at the user ID passed to the handleRequest method.

Checking the request against known values should come later after more tests are created that pass in different request strings. I’m sure you get the idea.

While I do like to follow steps, I think there’s a balance more toward writing a little extra code so that the TDD process doesn’t get too tedious. This initial service still does very little. And adding the logging and some of the initial structure to the code helps lay the foundation for what will come later. At least that’s my opinion.

For the logging, you’ll notice some things such as User(user) in the log calls. These are custom logging tags, like those we built in Chapter 10, The TDD Process in Depth. All the custom tags are defined in the last project file called LogTags.h, which looks like this:

#ifndef SIMPLESERVICE_LOGTAGS_H
#define SIMPLESERVICE_LOGTAGS_H
#include <MereMemo/Log.h>
namespace SimpleService
{
inline MereMemo::LogLevel error("error");
inline MereMemo::LogLevel info("info");
inline MereMemo::LogLevel debug("debug");
class User : public MereMemo::StringTagType<User>
{
public:
    static constexpr char key[] = "user";
    User (std::string const & value,
        MereMemo::TagOperation operation =
            MereMemo::TagOperation::None)
    : StringTagType(value, operation)
    { }
};
class LogPath : public MereMemo::StringTagType<LogPath>
{
public:
    static constexpr char key[] = "logpath";
    LogPath (std::string const & value,
        MereMemo::TagOperation operation =
            MereMemo::TagOperation::None)
    : StringTagType(value, operation)
    { }
};
class Request : public MereMemo::StringTagType<Request>
{
public:
    static constexpr char key[] = "request";
    Request (std::string const & value,
        MereMemo::TagOperation operation =
            MereMemo::TagOperation::None)
    : StringTagType(value, operation)
    { }
};
class Response : public MereMemo::StringTagType<Response>
{
public:
    static constexpr char key[] = "response";
    Response (std::string const & value,
        MereMemo::TagOperation operation =
            MereMemo::TagOperation::None)
    : StringTagType(value, operation)
    { }
};
} // namespace SimpleService
#endif // SIMPLESERVICE_LOGTAGS_H

This file defines the custom User, LogPath, Request, and Response tags. The named log levels, error, info, and debug, are also defined. All of the tags are placed in the same SimpleService namespace as the Service class.

Note that the logging project also included a file called LogTags.h, which was put in the tests folder because we were testing the logging itself. For this service project, the LogTags.h file is in the service folder because the tags are part of the service. We’re no longer testing that tags work. We’re not even testing that logging works. The tags get logged as part of the normal service operation, so they are now part of the service project.

With everything in place, we can build and run the test project, which shows the single test is passing. The summary report actually shows three tests passing because of the setup and teardown. The report looks like this:

Running 1 test suites
--------------- Suite: Service 1
------- Setup: Service 1
Passed
------- Test: Message can be sent and received
Passed
------- Teardown: Service 1
Passed
-----------------------------------
Tests passed: 3
Tests failed: 0

And we can also look at the log file, which contains these messages:

2022-08-14T05:58:13.543 log_level="info" Service is starting.
2022-08-14T05:58:13.545 log_level="debug" logpath="" user="123" Received: request="Hello"
2022-08-14T05:58:13.545 log_level="debug" logpath="" user="123" Sending: response="Hi, 123"

Now we can see the core structure that makes up a service. The service is first started and ready to handle requests. When a request arrives, the request is logged, the processing takes place to produce a response, and then the response is logged before sending the response back to the caller.

We’re using the testing library to simulate a real service by skipping over all the networking and routing and going straight to the service to start the service and handle requests.

We’re not going to add any more tests at this time. But for your own service projects, that would be your next step. You would add a test for each request type if your service supports multiple different requests. And don’t forget to add a test for an unrecognized request.

Each request type might have multiple tests for different combinations of request parameters. Remember that a real request in a real service will have the ability to define rich and complex requests where the request can specify its own set of parameters, just like how a function can define its own parameters.

Each request type will usually have its own response type. And you might have a common response type for errors. Either that or each response type will need to include fields for error information. It’s probably easier if your response types are used for successful responses and any error responses return a standard error response type that you define.

Another good idea when testing services is to create a logging tag for each request type. We only have a single greeting request but imagine a service with several different requests that can be handled. If each log message was tagged with the request type, then it becomes easy to enable debug logging for just one type of request.

Right now, we’re tagging the log messages with the user ID. This is another great way to enable debug-level logging without flooding the log file with too many log messages. We can set a filter to log debug log entries for a specific test user ID. We would also need a default filter set to info. We can then combine the user ID with the request type to get even more precise. Once the filters are set, normal requests will be logged at an info level while the test user gets everything logged for a specific request type.

Summary

Writing services requires a lot of supporting code and networking that monolithic applications don’t need. And the deployment and management of services are also more involved. So why would anybody design a solution that uses services instead of putting everything into a single monolithic application? Because services can help simplify the design of your applications, especially for very large applications. And because services run on distributed computers, you can scale a solution and increase reliability. Releasing changes and new features in your solution also becomes easier with services because each service can be tested and updated by itself. You don’t have to test one giant application and release everything all at once.

This chapter explored some of the different testing challenges with services and what can be tested. You were introduced to a simple service that skips routing and networking and goes straight to the core of what makes a service: the ability to start the service and handle requests.

The simple service developed in this chapter ties together the testing and the logging libraries, which are both used in the service. You can follow a similar project structure when designing your own projects that need to use both libraries.

The next chapter will explore the difficulties of using multiple threads in your testing. We’ll test the logging library to make sure it’s thread safe, learn what thread safety means, and explore how to test a service with multiple threads.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.117.183.150