Статьи

Простая история парадокса

Недавно я с интересом следил за дебатами #isTDDDead между Кентом Беком (@kentbeck), Дэвидом Хайнемайером Ханссоном (@dhh) и Мартином Фаулером (@martinfowler). Я думаю, что особенно полезно, что идеи, которые часто воспринимаются как должное, могут быть оспорены конструктивно. Таким образом, вы можете выяснить, выдерживают ли они проверку или падают лицевой стороной вниз на их лицах.

Обсуждение началось с того, что @dhh сделал следующие замечания о TDD и методике тестирования, которые, я надеюсь, я понял правильно. Во-первых, строгое определение TDD включает в себя следующее:

  1. TTD используется для тестирования юнитов
  2. Вы не можете иметь соавторов
  3. Вы не можете прикоснуться к базе данных
  4. Вы не можете коснуться файловой системы
  5. Быстрые юнит-тесты, завершенные в мгновение ока.

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

@Kent_Beck сказал, что TDD не обязательно включает в себя насмешки и обсуждение продолжается…

Я перефразировал немного здесь; однако, именно разница в интерпретации и опыте использования TDD заставила меня задуматься.

Была ли это действительно проблема с TDD или это был опыт @ dhh в интерпретации TDD от других разработчиков? Я не хочу помещать слова в рот @ dhh, но кажется, что проблема в догматическом применении техники TDD, даже если она не применима. У меня сложилось впечатление, что в некоторых разработках TDD превратился в нечто большее, чем программирование Cargo Cult.

RichardFeynman Термин «программирование культа грузов», по-видимому, происходит от статьи, написанной кем-то, кого я нашел действительно вдохновляющим, покойного профессора Ричарда Фейнмана. Он выступил с докладом « Наука о культовом грузе» — некоторые замечания о науке, лженауке и о том, как не обмануть себя, в качестве части обращения к Caltech в 1974 году. Позднее это стало частью его автобиографии: наверняка вы шутите, мистер Фейнман , книга, которую я умоляю вас прочитать.

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

Взятый из статьи 1974 года, Фейнман резюмирует Cargo Cult Science как:

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

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

Во втором выступлении в серии @dhh был приведен пример того, что он назвал «испытательным повреждением конструкции», и я был взволнован, потому что это то, что я видел несколько раз. Единственная оговорка, которая была у меня в отношении гист-кода, заключалась в том, что мне кажется, что он не является результатом TDD, этот аргумент кажется немного ограниченным; Я бы сказал, что это скорее результат программирования Cargo Cult, и это потому, что в тех случаях, когда я сталкивался с этим примером, TDD не использовался.

Если вы видели Gist , вы можете знать, о чем я говорю; тем не менее, этот код написан на Ruby, с чем у меня мало опыта. Чтобы изучить это более подробно, я подумал, что я бы создал версию Spring MVC и пошел оттуда.

Сценарий здесь один, где у нас очень простая история: все, что делает код, это считывает объект из базы данных и помещает его в модель для отображения. Нет никакой дополнительной обработки, никакой бизнес-логики и никаких вычислений для выполнения. Гибкая история будет выглядеть примерно так:

1
2
3
4
Title: View User Details
As an admin user
I want to click on a link
So that I can verify a user's details

В этом «правильном» N-уровневом примере у меня есть объект User модели, уровень контроллера и сервиса и DAO вместе с их интерфейсами и тестами.

И вот в чем парадокс: вы решили написать лучший код, какой только возможно, для реализации истории, используя хорошо известный и, вероятно, самый популярный шаблон слоя MVC ‘N’, и в итоге получили нечто такое, что полностью излишне для такого простого сценария. Что-то, как сказал бы @jhh, повреждено.

В моем примере кода я использую класс JdbcTemplate для получения сведений о пользователе из базы данных MySQL, но подойдет любой API доступа к БД.

Это пример кода, демонстрирующий традиционный, «правильный» способ реализации истории; готовлюсь сделать много прокрутки…

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class User {
  
  public static User NULL_USER = new User(-1, "Not Available", "", new Date());
  
  private final long id;
  
  private final String name;
  
  private final String email;
  
  private final Date createDate;
  
  public User(long id, String name, String email, Date createDate) {
    this.id = id;
    this.name = name;
    this.email = email;
    this.createDate = createDate;
  }
  
  public long getId() {
    return id;
  }
  
  public String getName() {
    return name;
  }
  
  public String getEmail() {
    return email;
  }
  
  public Date getCreateDate() {
    return createDate;
  }
}

01
02
03
04
05
06
07
08
09
10
11
12
13
14
@Controller
public class UserController {
  
  @Autowired
  private UserService userService;
  
  @RequestMapping("/find1")
  public String findUser(@RequestParam("user") String name, Model model) {
  
    User user = userService.findUser(name);
    model.addAttribute("user", user);
    return "user";
  }
}

1
2
3
4
5
public interface UserService {
  
  public abstract User findUser(String name);
  
}

01
02
03
04
05
06
07
08
09
10
11
12
13
14
@Service
public class UserServiceImpl implements UserService {
  
  @Autowired
  private UserDao userDao;
  
  /**
   * @see com.captaindebug.cargocult.ntier.UserService#findUser(java.lang.String)
   */
  @Override
  public User findUser(String name) {
    return userDao.findUser(name);
  }
}

1
2
3
4
5
public interface UserDao {
  
  public abstract User findUser(String name);
  
}

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Repository
public class UserDaoImpl implements UserDao {
  
  private static final String FIND_USER_BY_NAME = "SELECT id, name,email,createdDate FROM Users WHERE name=?";
  
  @Autowired
  private JdbcTemplate jdbcTemplate;
  
  /**
   * @see com.captaindebug.cargocult.ntier.UserDao#findUser(java.lang.String)
   */
  @Override
  public User findUser(String name) {
  
    User user;
    try {
      FindUserMapper rowMapper = new FindUserMapper();
      user = jdbcTemplate.queryForObject(FIND_USER_BY_NAME, rowMapper, name);
    } catch (EmptyResultDataAccessException e) {
      user = User.NULL_USER;
    }
    return user;
  }
}

Если вы посмотрите на этот код, как это ни парадоксально, он выглядит хорошо; на самом деле это похоже на классический пример учебника о том, как написать N-уровневое MVC-приложение. Контроллер передает ответственность за сортировку бизнес-правил на сервисный уровень, а сервисный уровень получает данные из БД с использованием объекта доступа к данным, который, в свою очередь, использует вспомогательный класс RowMapper<> для извлечения объекта User . Когда у контроллера есть объект User он внедряет его в модель, готовую для отображения. Эта модель ясна и расширяема; мы изолируем базу данных от сервиса и сервис от контроллера с помощью интерфейсов и тестируем все, используя как JUnit с Mockito, так и интеграционные тесты. Это должно быть последнее слово в кодировке MVC учебника, или это? Давайте посмотрим на код.

Во-первых, есть ненужное использование интерфейсов. Некоторые утверждают, что легко переключать реализации баз данных, но кто когда-либо это делает? 1 плюс, современные инструменты макетирования могут создавать свои прокси с использованием определений классов, поэтому, если ваш проект не требует нескольких реализаций одного и того же интерфейса, использование интерфейсов не имеет смысла.

Далее, существует UserServiceImpl , который является классическим примером ленивого анти-паттерна класса , потому что он ничего не делает, кроме бессмысленного делегирования объекту доступа к данным. Аналогично, контроллер также довольно ленив, поскольку он делегирует ленивый UserServiceImpl перед добавлением результирующего класса User в модель: фактически все эти классы являются примерами анти-паттерна lazy class.

Написав некоторые ленивые классы, они теперь без необходимости тестируются до смерти, даже класс UserServiceImpl без событий. Стоит писать тесты только для классов, которые действительно выполняют некоторую логику.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
public class UserControllerTest {
  
  private static final String NAME = "Woody Allen";
  
  private UserController instance;
  
  @Mock
  private Model model;
  
  @Mock
  private UserService userService;
  
  @Before
  public void setUp() throws Exception {
  
    MockitoAnnotations.initMocks(this);
  
    instance = new UserController();
    ReflectionTestUtils.setField(instance, "userService", userService);
  }
  
  @Test
  public void testFindUser_valid_user() {
  
    User expected = new User(0L, NAME, "[email protected]", new Date());
    when(userService.findUser(NAME)).thenReturn(expected);
  
    String result = instance.findUser(NAME, model);
    assertEquals("user", result);
  
    verify(model).addAttribute("user", expected);
  }
  
  @Test
  public void testFindUser_null_user() {
  
    when(userService.findUser(null)).thenReturn(User.NULL_USER);
  
    String result = instance.findUser(null, model);
    assertEquals("user", result);
  
    verify(model).addAttribute("user", User.NULL_USER);
  }
  
  @Test
  public void testFindUser_empty_user() {
  
    when(userService.findUser("")).thenReturn(User.NULL_USER);
  
    String result = instance.findUser("", model);
    assertEquals("user", result);
  
    verify(model).addAttribute("user", User.NULL_USER);
  }
  
}

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
public class UserServiceTest {
  
  private static final String NAME = "Annie Hall";
  
  private UserService instance;
  
  @Mock
  private UserDao userDao;
  
  @Before
  public void setUp() throws Exception {
  
    MockitoAnnotations.initMocks(this);
  
    instance = new UserServiceImpl();
  
    ReflectionTestUtils.setField(instance, "userDao", userDao);
  }
  
  @Test
  public void testFindUser_valid_user() {
  
    User expected = new User(0L, NAME, "[email protected]", new Date());
    when(userDao.findUser(NAME)).thenReturn(expected);
  
    User result = instance.findUser(NAME);
    assertEquals(expected, result);
  }
  
  @Test
  public void testFindUser_null_user() {
  
    when(userDao.findUser(null)).thenReturn(User.NULL_USER);
  
    User result = instance.findUser(null);
    assertEquals(User.NULL_USER, result);
  }
  
  @Test
  public void testFindUser_empty_user() {
  
    when(userDao.findUser("")).thenReturn(User.NULL_USER);
  
    User result = instance.findUser("");
    assertEquals(User.NULL_USER, result);
  }
}

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
public class UserDaoTest {
  
  private static final String NAME = "Woody Allen";
  
  private UserDao instance;
  
  @Mock
  private JdbcTemplate jdbcTemplate;
  
  @Before
  public void setUp() throws Exception {
  
    MockitoAnnotations.initMocks(this);
  
    instance = new UserDaoImpl();
    ReflectionTestUtils.setField(instance, "jdbcTemplate", jdbcTemplate);
  }
  
  @SuppressWarnings({ "unchecked", "rawtypes" })
  @Test
  public void testFindUser_valid_user() {
  
    User expected = new User(0L, NAME, "[email protected]", new Date());
    when(jdbcTemplate.queryForObject(anyString(), (RowMapper) anyObject(), eq(NAME))).thenReturn(expected);
  
    User result = instance.findUser(NAME);
    assertEquals(expected, result);
  }
  
  @SuppressWarnings({ "unchecked", "rawtypes" })
  @Test
  public void testFindUser_null_user() {
  
    when(jdbcTemplate.queryForObject(anyString(), (RowMapper) anyObject(), isNull())).thenReturn(User.NULL_USER);
  
    User result = instance.findUser(null);
    assertEquals(User.NULL_USER, result);
  }
  
  @SuppressWarnings({ "unchecked", "rawtypes" })
  @Test
  public void testFindUser_empty_user() {
  
    when(jdbcTemplate.queryForObject(anyString(), (RowMapper) anyObject(), eq(""))).thenReturn(User.NULL_USER);
  
    User result = instance.findUser("");
    assertEquals(User.NULL_USER, result);
  }
  
}

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
@RunWith(SpringJUnit4ClassRunner.class)
@WebAppConfiguration
@ContextConfiguration({ "file:src/main/webapp/WEB-INF/spring/appServlet/servlet-context.xml",
    "file:src/test/resources/test-datasource.xml" })
public class UserControllerIntTest {
  
  @Autowired
  private WebApplicationContext wac;
  
  private MockMvc mockMvc;
  
  /**
   * @throws java.lang.Exception
   */
  @Before
  public void setUp() throws Exception {
  
    mockMvc = MockMvcBuilders.webAppContextSetup(wac).build();
  }
  
  @Test
  public void testFindUser_happy_flow() throws Exception {
  
    ResultActions resultActions = mockMvc.perform(get("/find1").accept(MediaType.ALL).param("user", "Tom"));
    resultActions.andExpect(status().isOk());
    resultActions.andExpect(view().name("user"));
    resultActions.andExpect(model().attributeExists("user"));
    resultActions.andDo(print());
  
    MvcResult result = resultActions.andReturn();
    ModelAndView modelAndView = result.getModelAndView();
    Map<String, Object> model = modelAndView.getModel();
  
    User user = (User) model.get("user");
    assertEquals("Tom", user.getName());
    assertEquals("[email protected]", user.getEmail());
  }
  
}

При написании этого примера кода я добавил в микс все, что мог придумать. Вы можете подумать, что этот пример «перегружен» своей конструкцией, особенно с включением избыточного интерфейса, но я видел такой код.

Преимущества этого шаблона в том, что он следует особому дизайну, понятному большинству разработчиков; это чистый и расширяемый. Недостатком является то, что есть много классов. Большее количество классов требует больше времени для написания, и, когда вам когда-либо придется поддерживать или улучшать этот код, с ними труднее справиться.

Итак, каково решение? Это сложно ответить. В дискуссии #IsTTDDead @dhh предлагает решение, заключающееся в размещении всего кода в одном классе, смешивая доступ к данным с заполнением модели. Если вы реализуете это решение для нашей пользовательской истории, вы все равно получите класс User , но количество нужных вам классов резко сократится.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@Controller
public class UserAccessor {
  
  private static final String FIND_USER_BY_NAME = "SELECT id, name,email,createdDate FROM Users WHERE name=?";
  
  @Autowired
  private JdbcTemplate jdbcTemplate;
  
  @RequestMapping("/find2")
  public String findUser2(@RequestParam("user") String name, Model model) {
  
    User user;
    try {
      FindUserMapper rowMapper = new FindUserMapper();
      user = jdbcTemplate.queryForObject(FIND_USER_BY_NAME, rowMapper, name);
    } catch (EmptyResultDataAccessException e) {
      user = User.NULL_USER;
    }
    model.addAttribute("user", user);
    return "user";
  }
  
  private class FindUserMapper implements RowMapper<User>, Serializable {
  
    private static final long serialVersionUID = 1L;
  
    @Override
    public User mapRow(ResultSet rs, int rowNum) throws SQLException {
  
      User user = new User(rs.getLong("id"), //
          rs.getString("name"), //
          rs.getString("email"), //
          rs.getDate("createdDate"));
      return user;
    }
  }
}

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
@RunWith(SpringJUnit4ClassRunner.class)
@WebAppConfiguration
@ContextConfiguration({ "file:src/main/webapp/WEB-INF/spring/appServlet/servlet-context.xml",
    "file:src/test/resources/test-datasource.xml" })
public class UserAccessorIntTest {
  
  @Autowired
  private WebApplicationContext wac;
  
  private MockMvc mockMvc;
  
  /**
   * @throws java.lang.Exception
   */
  @Before
  public void setUp() throws Exception {
  
    mockMvc = MockMvcBuilders.webAppContextSetup(wac).build();
  }
  
  @Test
  public void testFindUser_happy_flow() throws Exception {
  
    ResultActions resultActions = mockMvc.perform(get("/find2").accept(MediaType.ALL).param("user", "Tom"));
    resultActions.andExpect(status().isOk());
    resultActions.andExpect(view().name("user"));
    resultActions.andExpect(model().attributeExists("user"));
    resultActions.andDo(print());
  
    MvcResult result = resultActions.andReturn();
    ModelAndView modelAndView = result.getModelAndView();
    Map<String, Object> model = modelAndView.getModel();
  
    User user = (User) model.get("user");
    assertEquals("Tom", user.getName());
    assertEquals("[email protected]", user.getEmail());
  }
  
  @Test
  public void testFindUser_empty_user() throws Exception {
  
    ResultActions resultActions = mockMvc.perform(get("/find2").accept(MediaType.ALL).param("user", ""));
    resultActions.andExpect(status().isOk());
    resultActions.andExpect(view().name("user"));
    resultActions.andExpect(model().attributeExists("user"));
    resultActions.andExpect(model().attribute("user", User.NULL_USER));
    resultActions.andDo(print());
  }
}

Приведенное выше решение сокращает количество классов первого уровня до двух: класс реализации и тестовый класс. Все тестовые сценарии учитываются в очень немногих комплексных тестах. Эти тесты будут обращаться к базе данных, но разве это так плохо в этом случае? Если каждая поездка в БД занимает около 20 мс или меньше, они все равно будут завершены в течение доли секунды; это должно быть достаточно быстро.

С точки зрения улучшения или поддержки этого кода, один маленький отдельный класс легче изучить, чем несколько даже меньших классов. Если вам пришлось добавить целую кучу бизнес-правил или другой сложности, то преобразование этого кода в шаблон уровня N не составит труда; однако проблема в том, что если / когда изменение необходимо, оно может быть предоставлено неопытному разработчику, который не будет достаточно уверен в том, чтобы выполнить необходимый рефакторинг. В результате, и вы, должно быть, уже много раз видели, что новое изменение может быть добавлено поверх этого единственного в своем роде решения, приводящего к путанице спагетти-кода.

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

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

1 Однажды за многие годы программирования я работал над проектом, в котором базовая база данных была изменена в соответствии с требованиями заказчика. Это было много лет и за тысячи километров, и код был написан на C ++ и Visual Basic.

Ссылка: Простой парадокс истории от нашего партнера по JCG Роджера Хьюза в блоге Captain Debug’s Blog .