Статьи

Использование TDD и прогрессивного улучшения для создания приложения чата


Я твердо верю в дизайн, управляемый тестами, и в прогрессивные усовершенствования, которые являются практически единственным способом создания надежных приложений с чистым, поддерживаемым кодом.
Когда я начал свою серию попыток изучить Dropwizard с самого начала, я хотел задокументировать, как я использую эти методы для создания приложения с нуля. Вы можете рассмотреть этот эпизод 1.5 в серии; это не конкретно о dropwizard, но он есть.

Что такое прогрессивное улучшение?

Если бы я попросил вас создать приложение для чата, вы, вероятно, начали бы задумываться о дизайне. Вы можете задать некоторые из следующих вопросов

— Использую ли я обычный HTTP или веб-сокеты?

— Какую базу данных я собираюсь использовать?

— Буду ли я поддерживать групповые чаты?

— Что я буду использовать для аутентификации?

— Какие технологии мы будем использовать в пользовательском интерфейсе?

Это может продолжаться в течение нескольких дней, идеально составляя план и дизайн, прежде чем мы начнем его выполнять. И это, конечно, разумные вопросы, но слишком легко увязнуть в разработке «идеальной» системы. Как разработчики мы знаем, что все эти благие намерения имеют тенденцию поражать поклонников, как только мы начинаем программировать. Прогрессивное улучшение — гораздо более прагматичный способ развития. Я использую следующий цикл:

  • Напишите тест для самого базового функционала, который может быть полезен / необходим

  •  Напишите самый простой неоптимизированный код для прохождения теста.

  •  При необходимости рефакторинг.

  •  Повторение

Это приводит нас прямо к коду, и мы думаем о проблемах в практическом, а не теоретическом смысле. В конечном итоге вы получите полное решение, но с быстрой обратной связью и хорошо проверенным кодом.

Практически это означает много недолговечного кода, который переписывается. Если я сначала создаю свое чат-приложение, используя список истории в памяти вместо формального бэкенда, мне придется вернуться назад и внести некоторые довольно фундаментальные изменения. Однако, используя TDD, мой код будет формироваться таким образом, чтобы сделать этот рефакторинг очень дешевым, и я буду исследовать проблемное пространство с помощью кода и тестов, а не составлять ручной список вещей, которые, я думаю, мне нужно будет рассмотреть , Рабочий код — единственная мера успеха.

For many this will be a very different way of working. I encourage you not to just read and forget this post but to try it out tomorrow at work. It’s a very satisfying way of working that results in beautiful, flexible code.

Applying This to a Chat Application

As mentioned earlier, I’m building a chat application, something like Whatsapp or telegram. What is the most basic piece of functionality we could test for in a chat application? Ideally, for one person to be able to send a message to another. To do that, we can ignore databases, authentication and all that other stuff. Let’s just send a message between two people. But we can break it down further; for this to happen, I need to be able to “see” a chat, so I need to be able to access a chats content. When a chat first starts, it will have no content in it. So,my first test should then be to retrieve a chat, and expect the chat to have no messages in it.

With progressive enhancement, the “nothing happens” test is often the first. It instantly gets you thinking about the API and design. To get to even this point, I’ve had to make a number of assumptions. I have had to decide that if two people are in the system and I request the chat log between the two of them, but they haven’t spoken, it will return an empty list of message (not null, not an error etc). And this choice is clearly documented by the test. This is why I love TDD- it results in a clear set of documentation in the form of tests. Below is the first test.

@Test

public void noChatTextForPeopleWhoHaventSpokenBefore() throws Exception {

WizChatApplication chatApplication = startServer();
HttpResponse<JsonNode> jsonResponse = get(chatApplication.url() + "/chat/jason/sookie").asJson();
assertThat(extractChat(jsonResponse), is(""));
}


private WizChatApplication startServer() throws Exception {
  WizChatApplication chatApplication = new WizChatApplication();
  chatApplication.main(new String[]{"server"});
  return chatApplication;
}



private String extractChat(HttpResponse<JsonNode> jsonResponse) {
return jsonResponse.getBody().getObject().getString("chat");
}

I’m writing the most basic test for the functionality. I’ve decided that I’m going to use REST because it’s easy to get started. If I want to change to websockets later for real time chats, I can do that later. I’ve also not used any authentication yet. That’s not important at this point. We’re just doing the basic chat functionality. We’ll write tests for auth later on when it’s important.

I’ve also gone for the most basic response, a single JSON node called “chat” which is basically just a big String with the chat in it. This isn’t sustainable long term probably; maybe it’ll end up as a list of chat message objects, specifying who sent the message, the media type etc. But for now, a string will do. Remember, we’re trying to do the smallest test to get things rolling, then will progressively enhance the code and tests as we move on.

@Path("/chat/{userOne}/{userTwo}")
@Produces(MediaType.APPLICATION_JSON)
public class ChatRoomResource {

  @GET
  public Chat chatBetween(@PathParam("userOne") Optional<String> userOne,
    @PathParam("userTwo") Optional<String> userTwo) {
    return new Chat(“”);
  }

public class Chat {
  private String chat;

  public Chat() {
  // Jackson deserialization
  }

  public Chat(String content) {
  this.chat = content;
  }

  @JsonProperty
  public String getChat() {
  return chat;
  }
}

It’s not the most exciting code, but we’re on the way to writing a chat application! Another reason I love TDD is because I can guarantee the application works- I made a number of basic errors when first coding up the implementation due to my unfamiliarity with Dropwizard and the test guaranteed I got it right before I went any further.

So far we’re fairly limited in functionality though; let’s actually have some chat text. The next test should be simple enough; if I submit a single message, it should show up in the chat.

@Test
public void chatReturnsMessagesThatHaveBeenSent() throws Exception {
WizChatApplication chatApplication = startServer();
    String message = "Hey Sookie";
    post(chatApplication.url() + "/chat/jason/sookie")
    .field("message", message)
.asJson();

    HttpResponse<JsonNode> jsonResponse = get(chatApplication.url() + "/chat/jason/sookie").asJson();

    assertThat(extractChat(jsonResponse), is("jason: " + message));

}

We’ve made another set of design decisions here; we’re using a post request with form data to send a message. The first name in the URL is the person sending the message, and the actual chat text should be preceded with {{username}}:.

None of these decisions are final. Chances are by the time we get to a scalable, realistic chat app this will be completely different. But the whole point of progressive enhancement is to start small and evolve. It forces decision to be made consciously. This results in a well designed, flexible application.

We’ve done the absolutely minimum to get the test to pass here- a solitary list of messages. By doing the least to pass we can see that clearly our tests are not up to scratch yet; that’s ok. We start with a simple test, and we refactor and improve as we go along.

I chose to make the next test cope with multiple chats. If I have conversations in two chats, can I properly retrieve them?

 @Test
    public void userWithMultipleChatsCanAccessAllChats() throws Exception {
        String message = "This a chat between bob and sue";
        post(chatApplication.url() + "/chat/bob/sue")
                .field("message", message)
                .asJson();

        String message2 = "This is a chat between Mike and Bob”;

        post(chatApplication.url() + "/chat/mike/bob")
                .field("message", message2)
                .asJson();

        HttpResponse<JsonNode> jsonResponseBobSue = get(chatApplication.url() + "/chat/bob/sue").asJson();
        HttpResponse<JsonNode> jsonResponseMikeDan = get(chatApplication.url() + "/chat/mike/bob").asJson();

        assertThat(extractChat(jsonResponseBobSue), is("bob: " + message));
        assertThat(extractChat(jsonResponseMikeDan), is("mike: " + message2));

    }

Unsurprisingly, this test fails with our solitary list of messages. We can now start to think about a more sensible implementation.

There’s a number of routes you can go down, but the important thing is to go simple. We’re not talking about persistence, searching or anything significant yet. As a result we just want a simple in memory solution. Initially I tried to go really dumb with a map of maps, but in the end I found that it was much easier to use a list of chat objects.

@Path("/chat/{userOne}/{userTwo}")
@Produces(MediaType.APPLICATION_JSON)
public class ChatRoomResource {

    List<Chat> chats = new LinkedList<Chat>();

    @GET
    public Chat chatBetween(@PathParam("userOne") final Optional<String> userOne,
                            @PathParam("userTwo") final Optional<String> userTwo) {
        String userOneName = userOne.get();
        String userTwoName = userTwo.get();
        return chats.stream()
                .filter(chat -> chat.isBetween(userOneName, userTwoName))
                .findFirst()
                .orElse(new Chat("", userOneName, userTwoName));
    }

    @POST
    public void newMessage(
            @PathParam("userOne") Optional<String> from,
            @PathParam("userTwo") Optional<String> to,
            @FormParam("message") Optional<String> message
    ) {
        String formattedMessage = from.get() + ": " + message.get();
        for (Chat chat : chats) {
            if (chat.isBetween(from.get(), to.get())) {
                chat.addMessage(formattedMessage);
                return;
            }
        }
        chats.add(new Chat(formattedMessage, from.get(), to.get()));

    }

}

public class Chat {
    private String chat;
    public String userOne;
    public String userTwo;

    public Chat() {
        // Jackson deserialization
    }

    public Chat(String content, String userOne, String userTwo) {
        this.chat = content;
        this.userOne = userOne;
        this.userTwo = userTwo;
    }


    @JsonProperty
    public String getChat() {
        return chat;
    }

    public void addMessage(String s) {
        chat += s;
    }

    public boolean isBetween(String s, String s1) {
        return userOne.equals(s) && userTwo.equals(s1) || userOne.equals(s1) && userTwo.equals(s);
    }
}

In three tests we’ve moved to a functioning chat application. Although we’ve no UI, it’s possible to use this as a functioning app by using Postman to submit our chats.

There’s a lot more tests and a lot more code to write, but hopefully this has given you an insight into using progressive enhancement as a way to explore requirements and develop robust code.

The chat app code is available here on github, but I’m working on it actively so you can see how I progress.