Web services (WS) are becoming a key architectural aspect of large-scale distributed applications. Technology leaders are both launching service-centric applications, such as Google Earth, eBay Web Services, and Amazon's E-Commerce Service, and creating tools that enable other organizations to publish web services. The standards-based, fault-tolerant, self-describing nature of web services enable the creation of loosely coupled applications that run on heterogeneous systems. Web services also enable interoperability between different online applications and enable innovative, synergistic combinations: mashups. While the prime directive for developers once was "put it on the Internet", the main goal now is often to "make it a Service Oriented Architecture (SOA)." For Agile developers, following best practices by doing Test-Driven Development (TDD) of WS code can require extra effort, but is definitely feasible. In this article, I present techniques and patterns for unit testing WS applications to enable TDD as well as general-purpose testing.
Web service applications are inherently multitier. The remote code calling the service methods can be a user's browser-based client or desktop application. It could also be another web service; Tim Berners-Lee and others have stated their expectations that the future Semantic Web will be based on automated agents exchanging information via web services (see "The Semantic Web," www.sciam.com/article.cfm?id=the-semantic-web). Service methods are implemented by the service interface, which contains functionality to generate, send, and receive messages. The service interface is distinct from the service's application logic, and in some cases, is automatically generated based on a service description in Web Services Description Language (WSDL) or a similar schema. So, every WS application has at least three tiers: the service application, the service interface, and the client interface.
In Figure 1, MyService is the service interface layer, MyApp is the service application, and ServiceStub is the client interface that enables remote code to call the web service. MyService implements the WS messaging, while MyApp contains application logic, business rules, data persistence, and other service implementation layers.
WS messages may contain complex objects serialized as XML, or simple HTTP GET/POST operations. Although it can be tempting to trust that WS messaging just works, generation and communication of such messages represents a significant potential point of failure, and screams for test coverage (at least for followers of the Agile testing mantra, "only the paranoid survive.") Because web services are a presentation layer of sorts, representing the front end of the WS application from the client's perspective, they can be tested using an approach that is analogous to testing GUI code (see Agile User Interface Development, www.onjava.com/pub/a/onjava/2004/11/17/agileuser_1.html), by separating the presentation layer from the application logic. In this case, the presentation layer is the WS messaging functionality. Following this approach, each method and behavior of the web service interface should have corresponding tests. However, to validate how the service methods work "over the wire," it's not sufficient just to create an instance of the service interface class and invoke its methods directly. The service methods must be validated by calling them via the client interface, the same way they are called in the live application. In addition to verifying the end-to-end functionality of the service methods, this also permits testing additional behaviors of the service, such as service resources, client connections, timeouts, and error handling. For these reasons, it is important to test SOA applications by making the tests interact with a live instance of the service, as in Figure 2.
The pseudocode in Listing One is from the test class MyServiceTest, showing how the test method testGetDoc() connects to the service, calls the service method getDoc(), and validates the results. The method setUp() creates the test's service connection, and tearDown() closes it.
public class MyServiceTest extends TestCase { public void setUp() { String URI = "http://localhost/services/MyService"; // ... create service endpoint and connect to URI ... MyService service = endpoint.getMyService(); } public void testGetDoc() { service.putDoc( "test" ); assertEquals( "test", service.getDoc() ); } public void tearDown() { service.destroy(); } }
When developing web services using TDD, the typical pattern is to first create a test for each new service behavior, then implement the service interface functionality that enables the test to pass. So, as with conventional objects, the organization of the test class tends to parallel that of the production class, with test methods that correspond to each service method or behavior. Figure 3 shows how service methods in MyService are validated by corresponding test methods in MyServiceTest.
Once the service interface has a test fixture, developing additional tests to validate the service is easy, and developers become accustomed to adding unit tests for every new behavior or bug fix associated with the service, just as for any other object. For example, we found that a service method called getImage() that returned a binary image data array had a serious bug, because if the service treated the data as a string, it could contain XML characters such as "<" and ">" that resulted in malformed SOAP messages. To fix this, we modified the WSDL declaration of getImage() to represent the data as a base64-encoded array, as in Listing Two.
<?xml version="1.0" encoding="UTF-8"?> <definitions name="MyService" targetNamespace="http://services.corp.com/namespaces/MyService"> <! ... > <! getImage > <xsd:element name="getImageRequest" type="xsd:string"/> <xsd:element name="getImageResponse"> <xsd:complexType> <xsd:sequence> <xsd:element name="data" type="xsd:base64Binary"/> </xsd:sequence> </xsd:complexType> </xsd:element> <! ... > </definitions>
The test method testGetImage() validates that getImage() succeeds in returning image data that exhibited the malformed message bug prior to the fix (Listing Three). It also validates that the data array has the expected size after being base64-encoded by the service, communicated as a WS message, and decoded by the client interface.
public class MyServiceTest extends TestCase { public void testGetImage() { GetImageResponse r = service.getImage( "test.image.1" ); assertEquals( 72000, r.getData().length ); } }
In addition to verifying the service methods' results for normal use cases, the tests should also validate error-handling characteristics; for example, are the expected exceptions or error codes generated by the service and caught by the client? The test method testInvalidLogin() validates that the service throws an expected login exception (Listing Four). This is not as trivial a test as it seems at first, since the service exception is also a WS message that is serialized and deserialized at the service and client endpoints.
public class MyServiceTest extends TestCase { public void testInvalidLogin() { try { service.login( "not_a_real_login", "bad_password" ); fail( "Expected LoginException not thrown" ); } catch ( LoginException e ) {} // expected exception } }
Service tests can validate behaviors that are difficult to test manually. The test method testTimeout() shows validation of a service connection timeout, a behavior that can be tested manually only by waiting for the normal session timeout to expire, or by altering and redeploying the service configuration to set a short timeout period, either of which are tedious (Listing Five).
public class MyServiceTest extends TestCase { public void testTimeout() { service.setTimeout( Calendar.SECOND, 1 ); try { Thread.sleep(1010); // wait for timeout } catch (InterruptedException e) {} try { service.putDoc( "test" ); // try to call service fail( "Expected RemoteException not thrown" ); } catch ( RemoteException e ) {} // expected exception } }
Another service behavior that is a pain to test manually is multiple client connections. The test method testMultipleConnections() validates that different clients have different service-side state by creating two client connections to the service, performing an operation on one, and verifying that the second client does not see the data added by the first (Listing Six).
public class MyServiceTest extends TestCase { public void testMultipleConnections() { MyService service1 = endpoint.getMyService(); MyService service2 = endpoint.getMyService(); service1.putDoc( "test" ); assertFalse( "test" == service2.getDoc() ); } }
Creating tests that exercise the web service not only enables unit testing of service methods, but also facilitates higher level testing, such as performance testing. A common performance criterion is to validate that a service method completes in less than a specified amount of time. This would be difficult to validate manually without instrumenting the code and performing user actions that fire service calls. The test method testGetDocTime() shows how to implement such a performance test (Listing Seven).
public class MyServiceTest extends TestCase { public void testGetDocTime() { long startTime = System.currentTimeMillis(); String doc = service.getDoc(); long endTime = System.currentTimeMillis(); assertTrue( endTime-startTime < 100 ); } }
Similarly, using the test framework to create stress tests of the service is simple. The test method testLargeDoc() validates that an arbitrarily large document can be sent to and retrieved from the service without alteration (Listing Eight).
public class MyServiceTest extends TestCase { public void testLargeDoc() { StringBuffer doc = new StringBuffer(1000000); // 1 MB string for (int i = 0; i<1000000; i++) doc.append("X"); service.putDoc( doc.toString() ); String d = service.getDoc(); assertEquals( doc.toString(), d ); } }
Another useful type of stress test is to create many client connections in separate threads, each asynchronously calling service methods, to validate the scalability of the service and application.
Using such testing techniques, web services can be automatically and repeatedly validated to provide a much higher level of quality than what is possible through ad hoc manual testing. Once the web service interface has full test coverage, the underlying application can be rapidly iterated with confidence that the WS messaging layer will work as expected. Given the increasing focus on and complexity of distributed, service-oriented architectures and applications, web service development and testing practices such as those described here will become increasingly indispensable.
Acknowledgments
This article resulted in part from Grid and web service development work at Tech-X Corporation, including the project "TxFlow: Data Skimming Grid Portal," led by Dr. David Alexander and funded by DOE contract DE-FG02-03ER83799, and the project "GRIDL: Grid-Enabled Interactive Data Language for Astronomical Data," led by Dr. Svetlana Shasharina and funded by NASA contract NNX09CA21P.