Недавно я с интересом следил за дебатами #isTDDDead между Кентом Беком (@kentbeck), Дэвидом Хайнемайером Ханссоном (@dhh) и Мартином Фаулером (@martinfowler). Я думаю, что особенно полезно, что идеи, которые часто воспринимаются как должное, могут быть оспорены конструктивно. Таким образом, вы можете понять, выдерживают ли они проверку или падают лицевой стороной вниз на их лицах.
Обсуждение началось с того, что @dhh сделал следующие замечания о TDD и методике тестирования, которые, я надеюсь, я понял правильно. Во-первых, строгое определение TDD включает в себя следующее:
- TTD используется для тестирования юнитов
- Вы не можете иметь соавторов
- Вы не можете прикоснуться к базе данных
- Вы не можете коснуться файловой системы
- Быстрые юнит-тесты, завершенные в мгновение ока.
Далее он сказал, что вы, следовательно, управляете архитектурой своей системы от использования имитаторов, и таким образом архитектура подвергается повреждению от диска, чтобы изолировать и высмеивать все, в то время как обязательное применение цикла «красный, зеленый, рефакторинг» слишком предписывающий. Он также заявил, что многие люди ошибаются из-за того, что вы не можете быть уверены в своем коде и что вы не можете предоставлять дополнительные функциональные возможности с помощью тестов, если не пройдете эту обязательную, хорошо проложенную дорогу TDD.
@Kent_Beck сказал, что TDD не обязательно включает в себя насмешки, и обсуждение продолжается …
Я немного перефразировал здесь; однако, именно разница в интерпретации и опыте использования TDD заставила меня задуматься.
Была ли это действительно проблема с TDD или это был опыт @ dhh в интерпретации TDD от других разработчиков? Я не хочу помещать слова в рот @ dhh, но кажется, что проблема в догматическом применении техники TDD, даже если она не применима. У меня сложилось впечатление, что в некоторых разработках TDD превратился в нечто большее, чем программирование Cargo Cult.
Термин «программирование культа грузов», по-видимому, происходит от статьи, написанной кем-то, кого я нашел действительно вдохновляющим, покойного профессора Ричарда Фейнмана. Он выступил с докладом «
Наука о культовом грузе» — некоторые замечания о науке, лженауке и о том, как не обмануть себя, в качестве части обращения к Caltech в 1974 году. Позднее это стало частью его автобиографии:
наверняка вы шутите, мистер Фейнман , книга, которую я умоляю вас прочитать.
В ней Фейнман рассказывает об экспериментах из нескольких лженаук, таких как наука об образовании, психологии, парапсихологии и физике, где научный подход к непредвзятости, расспросам обо всем и поиску недостатков в вашей теории был заменен верой, ритуализмом и верой: готовность принимать результаты других народов как должное вместо экспериментального контроля.
Взятый из статьи 1974 года, Фейнман резюмирует Cargo Cult Science как:
«В южных морях существует культ людей. Во время войны они видели, как самолеты приземлялись с большим количеством хороших материалов, и они хотят, чтобы то же самое произошло сейчас. Поэтому они организовали имитацию таких вещей, как взлетно-посадочные полосы, разжигание пожаров». вдоль взлетно-посадочных полос, чтобы сделать деревянную хижину для человека, чтобы сидеть, с двумя деревянными частями на его голове, как наушники и бамбуковые бруски, торчащие как антенны — он диспетчер — и они ждут самолеты, чтобы земля. Они все делают правильно. Форма идеальна. Она выглядит точно так же, как и раньше. Но это не работает. Никакие самолеты не приземляются. Поэтому я называю эти вещи грузом культовой науки, потому что они следуют всем очевидным заповедям и формы научного исследования, но они упускают что-то важное, потому что самолеты не приземляются «.Вы можете применить эту идею в программировании, где вы найдете команды и отдельных лиц, выполняющих ритуализированные процедуры и использующих методы, не понимая при этом теорию, стоящую за ними, в надежде, что они будут работать и потому что они «правильные вещи».
Во втором выступлении в серии @dhh был приведен
пример того, что он назвал «испытательным повреждением конструкции», и я был взволнован, потому что это то, что я видел несколько раз. Единственная оговорка, которая у меня была в отношении сути. код был в том, что мне кажется, что это не результат TDD, этот аргумент выглядит немного ограниченным, я бы сказал, что это был скорее результат программирования Cargo Cult, и это потому, что в тех случаях, когда я сталкивался с этим примером TDD не использовался.
Если вы видели
Gist , вы, возможно, знаете, о чем я говорю, однако этот код написан на Ruby, и у меня мало опыта. Для более подробного изучения этого вопроса. , Я думал, что я создам версию Spring MVC и уйду оттуда.
Сценарий здесь один, где у нас очень простая история: все, что делает код, это считывает объект из базы данных и помещает его в модель для отображения. Нет никакой дополнительной обработки, никакой бизнес-логики и никаких вычислений для выполнения. Гибкая история будет выглядеть примерно так:
Title: View User Details.
Как пользователь с правами администратора,
я хочу нажать на ссылку,
чтобы я мог проверить данные пользователя.
В этом «правильном» N-уровневом примере у меня есть объект пользовательской модели, уровень контроллера и сервиса и DAO вместе с их интерфейсами и тестами.
И в этом есть парадокс: вы решили написать лучший код, какой только возможно, для реализации истории, используя хорошо известный и, вероятно, самый популярный шаблон слоя MVC ‘N’, и в итоге получили такой результат, который полностью излишним для такого простого сценария. Что-то, как сказал бы @jhh, повреждено.
В моем примере кода я использую класс JdbcTemplate для получения сведений о пользователе из базы данных MySQL, но подойдет любой API доступа к БД.
Это пример кода, демонстрирующий традиционный, «правильный» способ реализации истории; готовлюсь сделать много прокрутки …
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;} } @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";} } public interface UserService { public abstract User findUser(String name);} @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);} } public interface UserDao { public abstract User findUser(String name);} @Repository public class UserDaoImpl implements UserDao { private static final String FIND_USER_BY_NAME = "SELECT id, name,email,createdDate FROM Users WHERE name=?"; @Autowire d 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 без событий. Стоит писать тесты только для классов, которые действительно выполняют некоторую логику.
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);} } 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);} } 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);} } @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, но количество нужных вам классов резко сократится.
@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()); } }
Приведенное выше решение сокращает количество классов первого уровня до двух: класс реализации и тестовый класс. Все тестовые сценарии учитываются в очень немногих комплексных тестах. Эти тесты будут обращаться к базе данных, но разве это так плохо в этом случае? Если каждая поездка в БД занимает около 20 мс или меньше, они все равно будут завершены в течение доли секунды; это должно быть достаточно быстро.
С точки зрения улучшения или поддержки этого кода, один маленький отдельный класс легче изучить, чем несколько даже меньших классов. Если вам пришлось добавить целую кучу бизнес-правил или другой сложности, то преобразование этого кода в шаблон уровня N не составит труда; однако проблема в том, что если / когда изменение необходимо, оно может быть предоставлено неопытному разработчику, который не будет достаточно уверен в том, чтобы выполнить необходимый рефакторинг. В результате, и вы, должно быть, уже много раз видели, что новое изменение может быть добавлено поверх этого единственного в своем роде решения, приводящего к путанице спагетти-кода.
При реализации такого решения вы, возможно, не очень популярны, потому что код нетрадиционный. Это одна из причин, по которой я думаю, что это решение для одного класса — то, что многие люди посчитают спорным. Именно эта идея стандартного «правильного» и «неправильного» написания кода, строго применяемого в каждом случае, привела к тому, что этот совершенно хороший дизайн стал проблемой.
Я думаю, что все дело в
лошадях на курсах ; Выбор правильного дизайна для правильной ситуации. Если бы я реализовывал сложную историю, я бы без колебаний разделил различные обязанности, но в простом случае это просто не стоит. Поэтому я закончу вопросом: есть ли у кого-нибудь лучшее решение для
простого парадокса истории? показано выше, пожалуйста, дайте мне знать.
1 Однажды за многие годы программирования я работал над проектом, в котором базовая база данных была изменена в соответствии с требованиями заказчика. Это было много лет и за тысячи километров, и код был написан на C ++ и Visual Basic.
Код для этого блога доступен на Github по адресу
https://github.com/roghughe/captaindebug/tree/master/cargo-cult.