Статьи

Разработка и тестирование клиентских сервисов GWT


Ранее на этой неделе Хирам
Кирино выпустил
RestyGWT , генератор GWT для служб REST и объектов передачи данных в кодировке JSON. Вы можете прочитать больше об этом в посте Хирама
RestyGWT, RPC Better GWT ?? , Прежде всего, я впечатлен RestyGWT, потому что он предоставляет то, что я всегда хотел с GWT: возможность вызывать сервисы RESTful и получать заполненный POJO в вашем обратном вызове , так же, как
AsyncCallback обеспечивает сервисы RPC.

RestyGWT также позволяет легко создавать сервисы, используя только интерфейсы и аннотации JAX-RS. Например:

import javax.ws.rs.POST;
...
public interface PizzaService extends RestService {
@POST
public void order(PizzaOrder request, MethodCallback<OrderConfirmation> callback);
}

После краткого обзора RestyGWT, я подумал, что было бы интересно поделиться тем, как я разрабатываю и тестирую клиентские сервисы GWT.

Разработка клиентских сервисов GWT
Письменные сервисы в приложении GWT могут быть полезны при использовании MVP, тем более что вы можете использовать EasyMock в тесте . В моих проектах GWT я часто использовал оверлейные типы, потому что они позволяют мне писать меньше кода и делают анализ JSON очень простым. У меня были проблемы с тестированием моих докладчиков при использовании типов наложения. Хорошей новостью является то, что я думаю, что я нашел разумное решение, но оно требует использования GWTTestCase. Если бы RestyGWT поддерживал типы оверлеев, есть хороший шанс, что я бы использовал его, тем более что его интеграционные тесты, похоже, тоже требуют GWTTestCase.

Вместо того, чтобы использовать обратные вызовы в моих докладчиках, я стараюсь использовать их только в моих реализациях служб. Таким образом, мои докладчики не должны беспокоиться о типах наложений и могут быть протестированы только для JUnit. Обратные вызовы в моих службах обрабатывают события синтаксического анализа / заполнения объектов и возникновения пожара с заполненными объектами.

GWT’s RequestBuilder is one option for communicating with RESTful services. The Development Guide for HTTP Requests explains how to use this class. To simplify REST requests and allow multiple callbacks, I’m using a RestRequest class, and a number of other utility classes that make up a small GWT REST framework (created by a former colleague).
RestRequest wraps RequestBuilder and provides a Fluent API for executing HTTP requests. Another class, Deferred, is a GWT implementation of Twisted’s Deferred.

As part of my service implementation, I inject an EventBus (with GIN) into the constructor and then proceed to implement callbacks that fire Events to indicate loading, saving and deleting has succeeded. Here’s an example service:

public class ConversationServiceImpl implements ConversationService {
private EventBus eventBus;

@Inject
public ConversationServiceImpl(EventBus eventBus) {
this.eventBus = eventBus;
}

public void getConversation(String name) {
Deferred<Representation> d =
RestRequest.get(URLs.CONVERSATION + "/" + URL.encode(name)).build();

d.addCallback(new Callback<Representation>() {
public void onSuccess(Representation result) {
Conversation conversation = convertResultToConversation(result);
eventBus.fireEvent(new ResourceLoadedEvent<Conversation>(conversation));
}
});

d.run();
}

public void saveConversation(Conversation conversation) {
Deferred<Representation> d = RestRequest.post(URLs.CONVERSATION)
.setRequestData(conversation.toJson()).build();

d.addCallback(new Callback<Representation>() {
public void onSuccess(Representation result) {
Conversation conversation = convertResultToConversation(result);
eventBus.fireEvent(new ResourceSavedEvent<Conversation>(conversation));
}
});

d.run();
}

public void deleteConversation(Long id) {
Deferred<Representation> d =
RestRequest.post(URLs.CONVERSATION + "/" + id).build();

d.addCallback(new Callback<Representation>() {
public void onSuccess(Representation result) {
eventBus.fireEvent(new ResourceDeletedEvent());
}
});

d.run();
}

/**
* Convenience method to populate object in one location
*
* @param result the result of a resource request.
* @return the populated object.
*/
private Conversation convertResultToConversation(Representation result) {
JSOModel model = JSOModel.fromJson(result.getData());
return new Conversation(model);
}
}

In the saveConversation() method you’ll notice the conversation.toJson() method call. This method uses a JSON class that loops through an objects properties and constructs a JSON String.

public JSON toJson() {
return new JSON(getMap());
}

Testing Services
In my experience, the hardest part about using overlay types is writing your objects so they get populated correctly. I’ve found that writing tests which read JSON from a file can be a great productivity boost. However, because of overlay types, you have to write a test that extends GWTTestCase. When using GWTTestCase, you can’t simply read from the filesystem. The good news is there is a workaround where you can subclass GWTShellServlet and overwrite GWT’s web.xml to have your own servlet that can read from the filesystem. A detailed explanation of how to do this was written by Alex Moffat in Implementing a -noserver flag for GWTTestCase.

Once this class is in place, I’ve found you can easily write services using TDD and the server doesn’t even have to exist. When constructing services, I’ve found the following workflow to be the most productive:

  1. Create a file with the expected JSON in src/test/resources/resource.json where resource matches the last part of the URL for your service.
  2. Create a *ServiceGwtTest.java and write tests.
  3. Run tests to make sure they fail.
  4. Implement the service and run tests to ensure JSON is getting consumed/produced properly to/from model objects.

Below is the code for my JsonReaderServlet.java:

public class JsonReaderServlet extends GWTShellServlet {

public void service(ServletRequest servletRequest, ServletResponse servletResponse)
throws ServletException, IOException {

HttpServletRequest req = (HttpServletRequest) servletRequest;
HttpServletResponse resp = (HttpServletResponse) servletResponse;

String uri = req.getRequestURI();
if (req.getQueryString() != null) {
uri += "?" + req.getQueryString();
}

if (uri.contains("/services")) {
String method = req.getMethod();
String output;

if (method.equalsIgnoreCase("get")) {
// use the part after the last slash as the filename
String filename = uri.substring(uri.lastIndexOf("/") + 1, uri.length()) + ".json";
System.out.println("loading: " + filename);
String json = readFileAsString("/" + filename);
System.out.println("loaded json: " + json);
output = json;
} else {
// for posts, return the same body content
output = getBody(req);
}

PrintWriter out = resp.getWriter();
out.write(output);
out.close();

resp.setStatus(HttpServletResponse.SC_OK);
} else {
super.service(servletRequest, servletResponse);
}
}

private String readFileAsString(String filePath) throws IOException {
filePath = getClass().getResource(filePath).getFile();
BufferedReader reader = new BufferedReader(new FileReader(filePath));
return getStringFromReader(reader);
}

private String getBody(ServletRequest request) throws IOException {
BufferedReader reader = new BufferedReader(new InputStreamReader(request.getInputStream()));
return getStringFromReader(reader);
}

private String getStringFromReader(Reader reader) throws IOException {
StringBuilder sb = new StringBuilder();
char[] buf = new char[1024];
int numRead;
while ((numRead = reader.read(buf)) != -1) {
sb.append(buf, 0, numRead);
}
reader.close();
return sb.toString();
}
}

This servlet is mapped to <url-pattern>/*</url-pattern> in a web.xml file in src/test/resources/com/google/gwt/dev/etc/tomcat/webapps/ROOT/WEB-INF.

My Service Test starts by getting an EventBus from GIN and registering itself to handle the fired events.

public class ConversationServiceGwtTest extends AbstractGwtTestCase
implements ResourceLoadedEvent.Handler, ResourceSavedEvent.Handler, ResourceDeletedEvent.Handler {
ConversationService service;
ResourceLoadedEvent<Conversation> loadedEvent;
ResourceSavedEvent<Conversation> savedEvent;
ResourceDeletedEvent deletedEvent;

@Override
public void gwtSetUp() throws Exception {
super.gwtSetUp();
DesigntimeGinjector injector = GWT.create(MyGinjector.class);
EventBus eventBus = injector.getEventBus();
service = new ConversationServiceImpl(eventBus);
eventBus.addHandler(ResourceLoadedEvent.TYPE, this);
eventBus.addHandler(ResourceSavedEvent.TYPE, this);
eventBus.addHandler(ResourceDeletedEvent.TYPE, this);
}

@SuppressWarnings("unchecked")
public void onLoad(ResourceLoadedEvent event) {
this.loadedEvent = event;
}

@SuppressWarnings("unchecked")
public void onSave(ResourceSavedEvent event) {
this.savedEvent = event;
}

public void onDelete(ResourceDeletedEvent event) {
this.deletedEvent = event;
}
}

After this groundwork has been done, a test can be written that loads up the JSON file and verifies the objects are populated correctly.

public void testGetConversation() {

service.getConversation("test-conversation");

Timer t = new Timer() {
public void run() {
assertNotNull("ResourceLoadedEvent not received", loadedEvent);
Conversation conversation = loadedEvent.getResource();
assertEquals("Conversation name is incorrect","Test Conversation", conversation.getName());

assertNotNull("Conversation has no channel", conversation.getChannel());
assertEquals("Conversation has incorrect task size", 3, conversation.getTasks().size());

convertToAndFromJson(conversation);
finishTest();
}
};

delayTestFinish(3000);
t.schedule(100);
}

private void convertToAndFromJson(Conversation fromJsonModel) {
Representation json = fromJsonModel.toJson();
assertNotNull("Cannot convert empty JSON", json.getData());

// change back into model
JSOModel data = JSOModel.fromJson(json.getData());
Conversation toJsonModel = new Conversation(data);
verifyModelBuiltCorrectly(toJsonModel);
}

private void verifyModelBuiltCorrectly(Conversation model) {
assertEquals("Conversation name is incorrect", "Test Conversation", model.getString("name"));
assertEquals("Conversation has incorrect task size", 3, model.getTasks().size());
assertEquals("Conversation channel is incorrect", "Web", model.getChannel().getString("type"));
}

Summary
This article has shown you how I develop and test GWT Client Services. If RestyGWT supported overlay types, there’s a good chance I could change my service implementation to use it and I wouldn’t have to change my test. Robert Cooper, author of GWT in Practice, claims he has a framework that does this. Here’s to hoping this article stimulates the GWT ecosystem and we get a GWT REST framework that’s as easy to use as GWT RPC.

Update: Today I enhanced this code to use Generics-based classes (inspired by Don’t repeat the DAO!) for the boiler-plate CRUD code in a service. In a nutshell, a service interface can now be written as:

public interface FooService extends GenericService<Foo, String> {

}

The implementation class is responsible for the URL and converting the JSON result to an object:

public class FooServiceImpl extends GenericServiceImpl<Foo, String> implements FooService {

public FooServiceImpl(EventBus eventBus) {
super(eventBus, "/services/foo");
}

@Override
protected Foo convertResultToModel(Representation result) {
return new Foo(JSOModel.fromJson(result.getData()));
}
}

I’m sure this can be further enhanced to get rid of the need to create classes altogether, possibly leveraging GIN or some sort of factory. The parent classes referenced in this code can be viewed at the following URLs:

There’s also a GenericServiceGwtTest.java that proves it all works as expected.

From http://raibledesigns.com/rd/entry/developing_and_testing_gwt_client