어떻게 하면 테스트를 더욱 가독성과 유지보수성을 가지게 할 수 있습니까
33529 단어 architecturetestingcleancodejava
소개
자동 테스트는 우리의 게임에서 매우 중요한 역할을 하고 있다.우리의 일상적인 업무에서 우리는 새로운 게임 기능을 추가하여 기존의 기능을 개선하거나 기술 채무에 따라 재구성했다.모든 변화는 실수를 초래하고 우리의 게임을 파괴할 수 있다.외부 의존 버전을 업데이트하는 등 간단한 변경이라도 의외의 행동을 초래할 수 있다.우리의 테스트는 경기가 시종 안정 상태에 있고 품질이 우리의 기대에 부합되는 것을 확보했다.
우리는 게임에서 몇 가지 유형의 자동 테스트를 사용했다.본고에서 나는 백엔드 시스템 통합 테스트를 중점적으로 소개할 것이다.이러한 테스트에서 우리는 전체 게임 백엔드를 하나의 전체로 테스트하고 실제 데이터베이스를 포함한다.일반적으로 다음 세 단계를 포함합니다.
우리가 줄곧 직면해 온 문제는 준비 단계가 매우 간단한 테스트 장면에서도 테스트를 읽을 수 없게 할 수 있다는 것이다.도시 건설자 놀이를 예로 들자.우리는 동전을 생산하는 건물에서 완제품을 수집하는 테스트를 쓰고 싶다.
준비 단계에는 다음 단계가 포함됩니다.
@Test
public void testCollectProduction() {
var player = playerTestHelper.createPlayer();
var city = cityTestHelper.createMainCity(player);
var building = buildingTestHelper.createBuilding(city, "SomeProductionBuilding");
productionTestHelper.createFinishedProduction(building, ResourceConstants.COINS, 20);
resourceTestHelper.setAmount(player, ResourceConstants.COINS, 100);
// call collect production endpoint
// assert that I have 120 coins
}
그러나 우리의 경험에 따르면 이것은 항상 그렇게 쉬운 것은 아니다.어떤 때는 더욱 유연해야 한다.조수 방법에서 더 많은 파라미터를 얻거나, 테스트의 가독성을 유지하기 위해 점점 더 많은 조수 방법을 만들어야 한다.준비 코드가 테스트의 나머지 부분보다 커서 매우 빨리 발생할 수 있습니다.한 달 후에 이 테스트를 보면, 무슨 일이 일어났는지 이해하는 데 시간이 좀 걸릴 것이다.테스트 설정 읽기 가능
나는 1년 후에 돌아오더라도 시험 내용을 즉각 이해하고 싶다.이를 실현하기 위해 우리는 유사한 생성기의 API를 도입했다. 이것은 우리가 장면 대상을 설정하여 전체 테스트 장면을 정의할 수 있도록 한다.
@Test
public void testCollectProduction() {
buildScenario(scenario -> scenario
.withPlayer(player -> player
.withResource(ResourceConstants.COINS, 100)
.withCity("MainCity", city -> city
.withBuilding("SomeProductionBuilding", building -> building
.withProduction(production -> production
.withResource(ResourceConstants.COINS)
.withAmount(20)
.withFinishNow()
)
)
)
)
);
// call collect production endpoint
// assert that I have 120 coins
}
보시다시피 테스트 설정은 읽기 쉽습니다.들여쓰기는 어느 설정이 어느 실체에 속하는지, 어느 실체가 어느 부모 실체에 속하는지 명확하게 나타낸다.구축기와 유사한 방법과 자동 완성도 이런 테스트를 흥미롭게 만든다.우리는 주입해야 할 테스트 조수류를 고려할 필요도, 내가 필요로 하는 기능을 제공할 수 있는 방법이 있는지도 고려할 필요가 없다.우리는 단지 첨부된 방법을 사용하여 장면 대상을 설정할 수 있다.실체의 실제 생성은 buildScenario()
방법의 배경에서 진행된다.그런데 저희가 어디서 생성된 아이디를 얻었죠?우리는 백그라운드에서 건축 실체를 만들었지만, 생산 수집 조작을 위해 생성된 ID를 알아야 한다.이 경우 참조 객체를 사용할 수 있습니다.
@Test
public void testCollectProduction() {
// create a reference object
var buildingRef = new AtomicReference<Building>();
buildScenario(scenario -> scenario
.withPlayer(player -> player
.withResource(ResourceConstants.COINS, 100)
.withCity("MainCity", city -> city
.withBuilding("SomeProductionBuilding", building -> building
.entityRef(buildingRef) // <-- reference object should contain this building entity
.withProduction(production -> production
.withResource(ResourceConstants.COINS)
.withAmount(20)
.withFinishNow()
)
)
)
)
);
// call collect production endpoint with buildingId: buildingRef.get().getId()
// assert that I have 120 coins
}
참고에 대해 우리는 어떠한 간단한 참고 소지자도 사용할 수 있다.Java는 AtomicReference 클래스를 추가했습니다. 이 클래스는 우리가 필요로 하는 기능을 제공하기 때문에 편리한 이유로 이 클래스를 사용합니다.우리는 단지 장면을 구축하기 전에 인용 대상을 만들 뿐이다. (물론 이 대상은 그때 비어 있다.)buildScenario()
에서 엔티티를 작성하면 참조 객체도 작성된 엔티티로 채워집니다.이것은 buildScenario()
을 호출한 후에 우리는 referenceObject.get()
을 통해 실체에 접근할 수 있음을 의미한다.너무 좋아요!그러나 다음 예에서는 참조 객체의 모든 기능이 명확하게 표시됩니다.만약 우리가 노동자가 있다고 가정한다면, 우리는 그들을 빌딩으로 파견하여 그것을 촉진시킬 수 있다.건축이 아직 건설되지 않았을 때, 우리는 어떻게 건축을 노동자에게 분배합니까?참조 객체만 사용하려면 다음과 같이 하십시오.
@Test
public void testCollectProduction() {
// create a reference object
var buildingRef = new AtomicReference<Building>();
buildScenario(scenario -> scenario
.withPlayer(player -> player
.withResource(ResourceConstants.COINS, 100)
.withCity("MainCity", city -> city
.withBuilding("SomeProductionBuilding", building -> building
.entityRef(buildingRef) // <-- reference will be filled with this building entity
.withProduction(production -> production
.withResource(ResourceConstants.COINS)
.withAmount(20)
.withFinishNow()
)
)
.withWorker(worker -> worker
.withAssignedBuilding(buildingRef) // <-- use the referenced building here
)
)
)
);
// ...
}
보시다시피 우리는 두 곳에서 같은 인용 대상을 사용했습니다.한 곳에서는 실체로 채워지고, 다른 곳에서는 우리가 만든 실체를 사용한다.물론 일꾼을 만들기 전에 건축을 먼저 만들어야 한다.그러나 테스트 자체에 대해 우리는 관심을 가질 필요가 없다.실시
두 가지 주요 구성 부분이 있다.장면 대상이 실행 중인 것을 보았습니다.이 클래스와 모든 하위 클래스를 구성 클래스라고 합니다.또한 실제 엔티티 생성을 담당하는 설정 클래스(Setup 접두사 포함)도 있습니다.
구성 클래스
모든 필드 논리나 실체는 테스트 수요로 존재할 수 있는 설정 클래스가 있어야 한다.루트 클래스는 GivenScenario 클래스입니다.이 클래스의 실례, 즉 장면 대상을 만들었습니다. 앞의 예시에서 보신
buildScenario()
방법을 호출하여 설정할 수 있습니다.우리의 예에서 Given Scenario 대상은 Given Player 대상 목록을 포함하고, Given Player는 유저 자원과 도시를 포함한다.이것은 반드시 당신의 업무 논리를 반영해야 합니다.따라서 만약에 한 건물이 한 도시에 속한다면 GivenCity 대상은 GivenBuilding 대상을 포함해야 한다.
구성 클래스는 보통...
public class GivenScenario {
@Getter
private final List<GivenPlayer> players = new ArrayList<>();
public GivenScenario withPlayer(Consumer<GivenPlayer> playerConsumer) {
GivenPlayer givenPlayer = new GivenPlayer();
playerConsumer.accept(givenPlayer);
players.add(givenPlayer);
return this;
}
}
public class GivenPlayer extends GivenEntity<GivenPlayer, Player> {
@Getter
private final Map<String, Long> resources = new HashMap<>();
@Getter
private final List<GivenCity> cities = new ArrayList<>();
public GivenPlayer withResource(ResourceConstants resourceId, long amount) {
resources.put(resourceId.getKey(), amount);
return this;
}
public GivenPlayer withCity(String cityDefinitionId, Consumer<GivenCity> cityConsumer) {
GivenCity givenCity = new GivenCity(cityDefinitionId);
cityConsumer.accept(givenCity);
cities.add(givenCity);
return this;
}
public GivenPlayer withCity(String cityDefinitionId) {
cities.add(new GivenCity(cityDefinitionId));
return this;
}
}
public abstract class GivenEntity<G, E> {
private AtomicReference<E> entityReference = new AtomicReference<>();
@SuppressWarnings("unchecked")
public G entityRef(AtomicReference<E> ref) {
if (entityReference.get() != null) {
ref.set(entityReference.get());
}
entityReference = ref;
return (G) this;
}
public E getEntity() {
var entity = entityReference.get();
if (entity == null) {
throw new IllegalStateException("Entity not set");
}
return entity;
}
public void setEntity(E entity) {
entityReference.set(entity);
}
}
설정 클래스
설정 클래스는 테스트 장면의 특정한 부분을 구축하는 것을 책임진다.대부분의 경우 각 도메인 논리 또는 엔티티에는 설정 클래스가 있습니다.
예를 들어, CitySetup 클래스는 장면에 정의된 모든 도시를 만듭니다.BuildingSetup 클래스는 건축물을 만들고 제품을 시작합니다.
다음은 CitySetup 클래스의 예입니다.
@Component
@Order(ScenarioSetupPartOrder.CITY)
@RequiredArgsConstructor
public class CitySetup implements ScenarioSetupPart {
private final CityService cityService;
private final CityInitService cityInitService;
private final GameDesignService gameDesignService;
@Override
public void setUp(GivenScenario scenario) {
scenario.getPlayers().forEach(givenPlayer -> givenPlayer
.getCities().forEach(givenCity -> createCity(givenPlayer, givenCity)));
}
private void createCity(GivenPlayer givenPlayer, GivenCity givenCity) {
Player player = givenPlayer.getEntity();
CityDefinition cityDefinition = gameDesignService.get(CityDefinition.class, givenCity.getCityDefinitionId());
City city = cityService.find(player.getId(), cityDefinition)
.orElseGet(() -> cityInitService.startNewCity(player, cityDefinition, Instant.now()));
givenCity.setEntity(city);
}
}
public interface ScenarioSetupPart {
void setUp(GivenScenario scenario);
}
public class ScenarioSetupPartOrder {
public static final int PLAYER = 1;
public static final int RESOURCE = 2;
public static final int CITY = 3;
public static final int EXPANSION = 4;
public static final int BUILDING = 5;
}
setUp()
방법은 완전한 장면 대상을 수신하지만 실체를 만드는 데 필요한 정보만 선택해야 한다.엔티티를 작성한 후 구성 객체(예: givenCity.setEntity(city)
)에 전달하고 다음 설정 클래스와 테스트에서 액세스할 수 있도록 참조 객체를 구성합니다.일부 설정 클래스는 다른 설정 클래스에 의존하기 때문에 순서가 매우 중요하다.예를 들어, CitySetup 클래스는 PlayerSetup 이전에 생성된 Player 엔티티에 액세스합니다.위의 예와 같이 각 설정 클래스의 Order-Annotation을 통해 순서를 확보할 수 있습니다.우리는 또한 주문서를 쉽게 유지하기 위해 ScenarioSetupPartOrder 클래스를 만들었다.순서 메모는 Spring 프레임의 일부입니다.의존 항목 목록(예:
List<ScenarioSetupPart>
)을 삽입하면 정의된 순서에 따라 목록을 정렬할 수 있습니다.그것을 한데 놓다
이제 구성과 설정 클래스를 정의했습니다. 기본 테스트 클래스에 넣어야 할
buildScenario()
방법을 볼 수 있습니다.protected void buildScenario(Consumer<GivenScenario> scenarioConsumer) {
scenario = new GivenScenario();
scenarioConsumer.accept(scenario);
scenarioSetup.setUp(scenario);
}
호출할 때마다 GivenScenario 객체가 생성됩니다.사용자 매개 변수는 호출자가 대상을 설정할 수 있도록 합니다.그리고 scenarioSetup.setUp()
방법을 호출합니다. 이 방법은 정확한 순서에 따라 특정한 설정 클래스에만 의뢰합니다.@Component
@RequiredArgsConstructor
public class ScenarioSetup {
private final List<ScenarioSetupPart> setupParts;
@Transactional
public void setUp(GivenScenario scenario) {
for (ScenarioSetupPart part : setupParts) {
part.setUp(scenario);
}
}
}
그렇습니다!새로운 기능을 만들 때, 설정과 관련된 설정 클래스만 만들 수 있습니다.이후 모든 테스트에 사용할 수 있습니다.결론
이런 테스트 설정 구조는 테스트의 가독성을 크게 향상시켰다.새로운 테스트를 작성하거나 장면을 확장하는 것은 매우 쉽고 일반적인 유지보수 작업량이 비교적 낮다.만약 우리가 작은 재구성을 하고 있다면, Setup 클래스를 업데이트하고 실제 테스트를 변하지 않게 유지하는 것만으로도 충분하다.
그러나 단점은 새로운 설정과 설정 클래스를 만들 때 항상 기억해야 한다는 것이다.예를 들어, 참조 엔티티를 설정하거나 클래스 순서를 올바르게 설정합니다.백그라운드에서 설정이 발생했기 때문에 테스트가 실패한 원인은 항상 뻔하지 않을 수도 있습니다.
그러나 만약 네가 더 멀리 생각한다면, 이 시스템은 우리를 위해 새로운 길을 열 것이다.준비 절차는 현재 구성 대상의 문제입니다.설정은 반드시 테스트에서 진행해야 할 뿐만 아니라또한 프런트엔드 자동화 테스트와 게임 내 커닝을 위한 API 노드를 만들었습니다.그들은 현재 어떠한 추가 노력도 없이 같은 시스템을 사용하여 유저를 설정할 수 있다.하지만 이것은 다음 박문이다.)
Reference
이 문제에 관하여(어떻게 하면 테스트를 더욱 가독성과 유지보수성을 가지게 할 수 있습니까), 우리는 이곳에서 더 많은 자료를 발견하고 링크를 클릭하여 보았다 https://dev.to/christianblos/how-to-make-your-tests-more-readable-and-maintainable-9m1텍스트를 자유롭게 공유하거나 복사할 수 있습니다.하지만 이 문서의 URL은 참조 URL로 남겨 두십시오.
우수한 개발자 콘텐츠 발견에 전념 (Collection and Share based on the CC Protocol.)