스트리밍은 대부분의 브라우저와
Developer 앱에서 사용할 수 있습니다.
-
Swift Testing으로 테스트 심화하기
Swift Testing에 추가된 기능으로 (테스트) 모음 세트를 작성하는 방법을 알아봅니다. 기본 구성 요소를 활용하여 더 많은 시나리오에 적용할 수 있도록 테스트를 확장하고, 다른 테스트 모음에서 테스트를 정리하고, 병렬로 실행되도록 테스트를 최적화하는 방법을 살펴보세요.
챕터
- 0:00 - Introduction
- 0:36 - Why we write tests
- 0:51 - Challenges in testing
- 1:21 - Writing expressive code
- 1:35 - Expectations
- 3:58 - Required expectations
- 4:29 - Tests with known issues
- 5:54 - Custom test descriptions
- 7:23 - Parameterized testing
- 12:47 - Organizing tests
- 12:58 - Test suites
- 13:33 - The tag trait
- 20:38 - Xcode Cloud support
- 21:09 - Testing in parallel
- 21:36 - Parallel testing basics
- 24:26 - Asynchronous conditions
- 26:32 - Wrap up
리소스
- Adding tests to your Xcode project
- Forum: Developer Tools & Services
- Improving code assessment by organizing tests into test plans
- Running tests and interpreting results
- Swift Testing
- Swift Testing GitHub repository
- Swift Testing vision document
관련 비디오
WWDC24
WWDC21
-
다운로드
안녕하세요, 저는 Swift Testing 팀의 Jonathan입니다 제 동료인 Dorothy와 함께 테스트 개발을 한 단계 업그레이드할 수 있는 Swift Testing의 강력한 기능 몇 가지를 소개해 드리고자 합니다 Swift Testing은 Swift의 강력하고 표현력 있는 기능을 위한 최신 오픈 소스 테스트 라이브러리로 Xcode 16에 포함되어 있습니다 아직 시청하지 않으셨다면 ‘Swift Testing 소개’를 시청하여 Swift Testing의 기초에 대해 알아보세요
테스트는 개발 프로세스의 중요한 단계입니다 코드가 사용자에게 전달되기 전에 문제를 발견하고 뛰어난 품질의 제품을 출시할 수 있다는 자신감을 줍니다 하지만 프로젝트에서 계속 늘어나는 테스트 모음을 유지 관리하다 보면 어려움을 겪게 될 수 있습니다 테스트는 코드의 동작을 문서화하고 적용합니다 코드가 복잡해질수록 테스트를 읽고 이해하기 쉽도록 만드는 것이 더욱 중요해집니다 코드에서 발생할 수 있는 모든 에지 케이스를 다루려면 많은 생각과 노력이 필요합니다 대규모 테스트 컬렉션을 구성하고 연관 짓는 것은 복잡한 일이 될 수 있으며 테스트 사이에 숨겨진 종속성으로 인해 테스트가 취약해지고 예기치 않은 오류가 발생할 수 있습니다 Dorothy와 함께 시작해 보겠습니다 테스트의 가독성은 중요합니다 테스트가 읽기 쉬우면 작업도 쉬워지고 특히 코드가 점점 더 복잡해질수록 테스트 실패를 더 이해하기 쉽게 만듭니다 Swift Testing의 여러 기능은 명확하고 표현력 있는 테스트 작성에 도움을 줍니다 기대치는 Swift Testing에서 코드가 예상대로 작동하는지 확인하는 방법입니다 기대치는 Swift 언어 기능과 구문을 활용하여 표현력이 풍부하고 간결한 인터페이스를 제공합니다
다음은 기대치의 몇 가지 예입니다 true 또는 false으로 평가되는 간단한 표현식이 있죠 그러나 expect 매크로는 훨씬 더 강력하고 훨씬 더 복잡한 검증을 처리할 수 있습니다 오류 처리의 테스트는 덜 수행되는 경우가 많지만 사용자 경험의 중요한 부분입니다 잘못된 입력과 예상치 못한 조건을 마주친 경우에도 코드가 깔끔하게 실패하도록 해야 합니다 expect throws 매크로는 기대치를 기반으로 하여 이 작업 흐름을 더 쉽게 만듭니다 코드의 해피 패스를 테스트하고 throw 함수가 성공적으로 반환되기를 기대하고 있다면 테스트 내부에서 호출하면 됩니다 함수에서 오류가 발생한다면 테스트가 실패합니다 함수가 성공적으로 실행되고 값을 반환하면 기대치를 사용하여 예상과 맞는지 확인할 수 있습니다 반면, 실패 사례가 의도대로 작동하는지 확인하려면 함수가 발생시킨 오류를 catch하고 검사해야 합니다 코드 주위에 do catch 구문을 추가하고 오류를 검사하는 방법이 있지만 코드가 장황해지고 오류가 발생하지 않으면 이 코드로는 모든 것이 제대로 진행되었는지 알 수 없습니다 여기서 Swift Testing이 expect throws 매크로로 도움을 줍니다 do catch 구문을 직접 작성하는 대신 expect throws 매크로가 어려운 작업을 대신 합니다 brew 함수가 오류를 발생시키면 이 테스트가 통과합니다 오류가 없으면 테스트가 실패합니다
특정 유형의 오류가 발생했는지 확인하려면 모든 Error 대신 해당 타입을 전달할 수 있습니다 이제 오류가 발생하지 않거나 BrewingError의 인스턴스가 아닌 오류가 발생하면 이 테스트는 실패합니다 한 단계 더 나아가서 특정 오류가 발생했는지 더욱 철저하게 검증할 수 있습니다 대부분의 복잡한 경우 expect throws 매크로 유효성 검사를 맞춤화할 수도 있습니다 오류 유효성 검사를 맞춤화해 특정 타입이나 오류 사례 연결된 값 또는 속성이 올바른지 여부 또는 코드에서 발생한 오류가 작업에 맞는 오류인지 확인하는 등 필요한 모든 것을 확인할 수 있습니다 ‘Swift Testing 소개’ 세션에서 Stuart가 필수 기대치의 개념을 소개했습니다 다시 복기해 보자면 발생하는 기대값을 포함한 모든 일반 기대값을 필수 기대값으로 만들 수 있습니다 옵셔널 값의 유효성을 검사하는 경우 필수 기대값을 사용하여 제어 흐름을 문서화할 수 있습니다
마지막에 nil이 된 값을 검사하는 것은 의미가 없으므로 필수 기대값을 추가하면 테스트가 더 이상 할 일이 없는 경우 조기에 종료할 수 있습니다 다음으로 테스트에서 알려진 오류를 문서화하기 위해 withKnownIssue 함수 사용법을 알아 보겠습니다 테스트 실패 분류는 시간이 많이 걸립니다 실패한 테스트를 즉시 수정할 수 없거나 통제할 수 없는 요인으로 인해 테스트가 실패하는 경우 테스트 결과에 노이즈가 추가되어 고객이 실제로 겪는 문제를 보지 못하게 될 수 있습니다 이 테스트는 아이스크림 기계가 아이스크림 콘을 만들 수 있는지 확인하는데 현재 기계가 작동하지 않아서 테스트가 실패한 것 같습니다 기술자가 기계를 수리할 때까지 시간이 걸릴 수 있습니다 기술자가 올 때까지 기다리고 있는데 이 테스트에서 비활성화 특성을 사용하면 좋겠다는 느낌이 옵니다 하지만 이 경우에는 withKnownIssue를 사용하는 것이 더 낫습니다 테스트는 계속 실행되고 컴파일 오류에 대한 알림을 받게 됩니다 함수가 오류를 반환하는 경우 이는 예상했던 오류이기 때문에 테스트 결과가 테스트 실패에 포함되지 않습니다 대신 결과에서 예상했던 실패로 표시됩니다 문제가 해결되어 더 이상 오류가 발생하지 않으면 알림이 나타나며 withKnownIssue 호출을 제거하고 테스트를 다시 정상적으로 실행할 수 있습니다 테스트에서 여러 확인을 수행할 수도 있습니다
이 경우 실패한 함수를 withKnownIssue로 래핑하고 나머지 유효성 검사가 실행되도록 할 수 있습니다 이 섹션에서 마지막으로 다룰 주제는 맞춤화 테스트 설명입니다 완벽한 세상에서는 모든 테스트가 항상 통과하고 아이스크림 기계도 항상 작동합니다 하지만 현실에서는 테스트가 실패를 합니다 맞춤화 테스트 설명으로 테스트 내부에서 일어나는 일을 한눈에 파악할 수 있으며 문제가 발생했을 때 해결 방법을 안내할 수 있습니다 간단한 열거형으로 작업할 때 기본 설명은 일반적으로 간결하고 간단합니다 하지만 구조와 클래스 같은 더 복잡한 타입은 더 많은 노이즈를 생성할 수 있는데 기본적으로 해당 타입의 설명에 테스트 중에는 쓸모 없는 추가 데이터가 다수 포함되어 있기 때문입니다
이러한 값은 많은 정보와 함께 Xcode에 표시됩니다 정보는 정확하지만 한 값을 다른 값과 구별하는 중요한 부분을 찾기가 어려울 수 있습니다 이 경우 프로덕션 코드에 영향을 주지 않으면서 각각에 간결한 테스트 설명을 제공할 수 있습니다 맞춤형 테스트별 설명을 제공할 수 있는 CustomTestStringConvertible 프로토콜을 준수하는 타입을 만들 수 있습니다 이제 테스트 내비게이터와 테스트 보고서 모두에서 훨씬 더 가독성 높고 설명이 풍부한 값을 얻을 수 있습니다! 발생한 오류 처리법, 필요한 기대치에 따른 테스트 조기 종료 방법 알려진 문제를 처리하고 테스트 출력을 읽기 쉽게 만드는 방법을 알아보았습니다 이제 여러분의 테스트는 무엇이든 처리할 준비가 되었습니다! 다시 Jonathan에게 넘기겠습니다 앞서 언급했듯이 코드의 품질 유지에 있어 어려운 점 중 하나는 모든 에지 케이스를 다루는 것입니다 일상적인 테스트에서는 아주 드물게 발생하는 복잡한 기능의 일부를 말이죠 에지 케이스를 포착하기 위해 다양한 조건에서 테스트를 실행하는 것이 좋지만 이 작업은 시간이 많이 걸렸으며 가능한 모든 변수에 대해 별도의 테스트를 작성하는 것은 끔찍한 유지관리 작업이었습니다 Swift Testing을 사용하면 다양한 인수로 단일 테스트 함수를 쉽게 실행할 수 있습니다 무슨 말인지 보여 드리죠 이 열거 목록에 다양한 아이스크림 맛이 나열되어 있고 특정 맛에 견과류가 포함됐는지 확인할 수 있다고 해 보겠습니다 containsNuts 속성은 열거의 모든 케이스에 대한 테스트 커버리지가 필요합니다 Swift Testing의 매개변수화된 테스트로 테스트 커버리지를 쉽게 추가할 수 있습니다
이 테스트 함수는 vanilla 열거형의 케이스 중 하나에 견과류가 포함되었는지 확인합니다 열거형의 각 케이스에 별도의 테스트 함수를 작성할 수도 있지만 코드가 너무 많아집니다 같은 함수를 여러 번 복사하여 붙여넣다 보면 실수로 잘못된 값을 확인하기 쉽습니다 여기에는 대신 한두 개의 테스트 함수만 있으면 됩니다 결국 테스트의 로직은 단일 입력 값을 제외하고는 동일합니다 이것이 바로 매개변수화된 테스트로 하나 이상의 매개변수를 사용하는 테스트입니다 테스트 함수가 실행되면 Swift Testing이 자동으로 인수당 하나씩 별도의 테스트 케이스로 분할합니다
이러한 테스트 케이스는 서로 완전히 독립적이며 병렬로 실행할 수 있습니다 따라서 모든 테스트 케이스를 테스트하는 데 필요한 시간이 for 루프나 별도의 테스트 함수를 사용할 때보다 줄어듭니다 Xcode는 입력 유형이 Codable을 준수하는 경우 테스트 함수의 개별 테스트 케이스 재실행을 지원합니다 이를 통해 성공적으로 실행된 다른 테스트 케이스를 다시 실행할 필요 없이 실패한 테스트 케이스만 재시도할 수 있습니다 테스트 함수를 매개변수화하는 방법을 좀 더 자세히 살펴보겠습니다 먼저 열거형의 모든 경우를 반복하고 각 경우를 테스트하는 테스트 함수를 작성하는 것으로 시작할 수 있습니다 함수가 작동하지만 개선할 수 있을 것입니다 문제 하나가 눈에 띕니다 이 테스트 함수가 배열의 값 중 하나에 대해 실패하면 실행이 중단되고 후속 인수를 테스트하지 않습니다 어떤 값이 실패했는지 불분명할 수 있으며 원하는 커버리지를 얻지 못할 수 있습니다 테스트 함수 내에서 입력을 반복하는 대신 입력을 위로 이동시켜 테스트 속성으로 전달할 수 있습니다 매개변수화를 위해 컬렉션을 Test 속성으로 전달하면 테스트 라이브러리는 컬렉션의 각 요소를 한 번에 하나씩 첫 번째이자 유일한 인수로 테스트 함수에 전달합니다 그런 다음 이러한 인수 중 하나의 테스트가 실패하면 해당 진단을 통해 어떤 입력에 주의가 필요한지 명확하게 표시합니다 거의 완료되었네요! 견과류가 포함된 맛을 테스트하는 두 번째 함수를 추가하면 이 열거형의 코드 커버리지를 완벽하게 확보할 수 있습니다 나중에 열거형을 확장하면 새 테스트 케이스를 쉽게 추가할 수 있습니다
이 예에서는 열거형의 경우를 살펴봤지만 매개변수화된 테스트 함수는 다른 종류의 입력도 받을 수 있습니다 배열, 딕셔너리, 범위 등 모든 Sendable 컬렉션을 테스트 속성에 전달할 수 있습니다
테스트 케이스는 인수와 함께 각 인수에 실행 버튼이 표시되는 테스트 내비게이터와 실패한 매개변수화된 테스트에 대한 풍부한 정보를 볼 수 있는 테스트 보고서 모두에 표시됩니다 지금까지 하나의 입력으로 매개변수화된 테스트 작성법을 배웠는데 더 많은 인수를 전달해야 한다면 어떻게 해야 할까요? Swift Testing의 테스트 함수는 여러 입력을 받을 수 있으며 첫 번째 인수 뒤에 다른 인수를 추가하기만 하면 이 테스트에 다른 인수를 추가할 수 있습니다 첫 번째 컬렉션의 모든 요소는 테스트 함수의 첫 번째 인수로 전달되고 두 번째 컬렉션의 모든 요소는 두 번째 인수로 전달됩니다 이 두 컬렉션의 모든 요소 조합이 자동으로 테스트됩니다 하나의 테스트 함수에서 모든 조합을 테스트할 수 있게 함은 테스트 커버리지를 개선하는 강력한 방법입니다! 이 강력함을 시각화하기 위해 두 개의 인수 배열, 즉 원재료와 이 재료로 만들 수 있는 음식을 고려해 보겠습니다 인수가 두 개인 테스트 함수는 가능한 모든 조합을 테스트합니다 총 열여섯 개군요 어딘가 이상한 조합을 맛보게 될 수도 있습니다 저도 계란 샐러드, 오므라이스를 정말 좋아하는데 여기 이 양상추와 감자튀김은 무슨 조합일까요? 그리고 각 배열에 입력된 값도 네 개뿐입니다 두 세트에 더 많은 입력을 추가하면 테스트 케이스의 수는 기하급수적으로 계속 늘어납니다! 이러한 폭발적 증가를 제어하기 위해 테스트 함수는 최대 두 개의 컬렉션을 허용합니다 Swift 표준 라이브러리의 zip 함수를 사용하여 함께 사용해야 하는 입력 쌍을 짝지을 수도 있습니다 원재료와 최종 요리의 모든 조합을 테스트하는 대신에요 zip을 호출하면 쌍으로 연결됩니다 Zip은 튜플의 시퀀스를 생성합니다 첫 번째 컬렉션의 각 요소를 두 번째 컬렉션의 요소와 순서대로 쌍으로 묶는 방식이며 그 외에는 아무것도 생성하지 않습니다 이제 매개변수화된 테스트를 통해 테스트 범위를 확장하는 데 필요한 도구를 갖추게 되었습니다! 다시 Dorothy가 테스트 구성을 설명드릴 것입니다 더 많은 테스트 작성에 도움이 되는 이 모든 새로운 기능을 사용하려면 이를 관리할 전략이 필요합니다 Swift Testing에서 테스트 정리를 위해 제공하는 도구를 살펴보겠습니다 아시겠지만, 모음이란 테스트 기능을 포함하는 타입입니다 표시 이름과 같은 특성으로 이러한 기능을 문서화할 수 있습니다 Swift Testing의 새로운 기능인 모음에 이제 다른 모음 포함할 수 있어 테스트를 더욱 유연하게 구성할 수 있습니다 아주 잘 만들어진 테스트 세트지만 명확하게 정리되어 있지는 않네요 테스트 모음에는 따뜻한 디저트와 찬 디저트에 대한 테스트가 포함되어 있습니다 하위 모음을 추가하면 테스트 자체에 이러한 구성을 반영하고 테스트 그룹 사이의 관계를 더 명확하게 만들 수 있습니다 태그는 테스트 정리에 도움이 되는 또 다른 특성입니다 복잡한 패키지나 프로젝트에는 수백 또는 수천 개의 테스트와 모음이 포함될 수 있습니다 코드의 여러 부분을 다루는 여러 테스트 모음이 있을 수도 있습니다 직접 연결되어 있지는 않아도 일부 테스트의 하위 세트는 공통된 특성을 공유할 수 있습니다 이 예시에서 일부 테스트는 카페인이 함유된 음식과 관련있고 일부 테스트는 초콜릿이 든 음식과 관련이 있습니다 이 경우 태그로 두 세트에서 테스트를 연결할 수 있습니다 유의할 점은 태그는 테스트 모음을 대체할 수 없다는 겁니다 모음은 소스 수준에서 테스트 함수에 구조를 부여하지만 태그는 공통점을 공유하는 서로 다른 파일, 모음, 대상의 테스트를 연결할 수 있도록 합니다 이런 태그를 어떻게 선언하고 테스트에 추가할 수 있을까요? 이 모든 음료에 카페인이 들어 있으며 에스프레소 브라우니에도 마찬가지입니다 caffeinated 태그를 만들어 테스트가 별도의 모음에 존재해도 서로 연관시킬 수 있습니다 먼저 Tag 타입을 확장해 이름이 caffeinated인 정적 변수를 선언합니다 변수는 Tag의 인스턴스여야 합니다 그리고 비밀 재료인 tag 속성을 변수에 추가해 테스트에서 태그로 사용할 수 있도록 합니다
이제 태그를 만들었으니 이 테스트에 추가할 수 있습니다 태그는 DrinkTests의 모음 수준에서 추가할 수 있습니다 이 테스트에 사용된 모든 음료는 카페인이 들어있고 테스트는 해당 모음에서 태그를 상속하기 때문입니다 그런 다음 espressoBrownieTexture에 추가할 수 있습니다 DessertTests에 있는 유일한 카페인이 든 식품이므로 전체 DessertTests 모음에 추가하고 싶지 않을 것입니다
모음와 테스트에는 태그가 여러 개 있을 수도 있습니다 모카와 에스프레소 브라우니는 모두 카페인이 있지만 재료가 초콜릿이기도 합니다! chocolatey 태그를 만들어 이 두 테스트에 추가할 수 있습니다 테스트 내비게이터가 테스트를 태그별로 그룹화하며 특정 태그가 있는 테스트를 실행할 수 있게 합니다 Xcode 16에서의 태그 사용 방법을 살펴보겠습니다 테스트 내비게이터의 새로운 기능들은 태그가 지정된 테스트 작업에 도움을 줍니다 내비게이터는 기본적으로 소스 코드에서 해당 위치에 따라 정리된 테스트를 표시합니다 저는 극비 핫소스 레시피를 완성하느라 바빴고 이를 사용하는 코드에 대한 테스트 커버리지를 개선하고 싶었습니다 제가 미리 작성한 테스트입니다 테스트 내비게이터 하단의 필터 필드를 사용해 핫소스 관련 테스트를 찾아보겠습니다 입력을 시작하면 프로젝트에서 사용 가능한 태그를 기반으로 Xcode가 태그를 제안합니다 이제 Xcode가 seasonal, spicy street food와 같은 태그 제안을 표시합니다 계속 입력하여 결과 범위를 좁혀 보겠습니다 기본적으로 필터 필드는 테스트의 표시, 함수 이름과 일치합니다 따라서 진저브레드 쿠키의 스파이스 조합과 시금치와 아티초크 딥소스 비율 등의 테스트가 표시됩니다 또한 Xcode는 이름에 강조 표시된 단어가 없는 테스트를 제안합니다 제가 입력한 내용과 일치하는 태그가 있기 때문입니다 제안 팝업에서 spicy를 클릭하면 입력한 내용을 테스트 내비게이터가 태그 필터로 변환하여 해당 태그가 없는 모든 테스트를 제거합니다 이제 spicy 태그가 있는 테스트만 테스트 내비게이터에 표시됩니다 테스트 내비게이터는 테스트를 태그별로 그룹화할 수도 있습니다 이 뷰로 전환하기 위해 테스트 내비게이터 상단의 태그 아이콘을 클릭합니다 태그 필터를 제거해 프로젝트의 모든 태그가 결과에 표시되도록 하겠습니다 이 뷰를 사용해 테스트를 편리하게 실행할 수 있습니다 계층 구조 뷰와 마찬가지로 태그 옆의 재생 버튼을 클릭하면 해당 태그가 있는 모든 테스트를 실행할 수 있습니다 개발 중에도 핫소스 관련 테스트를 실행하고 빠른 피드백을 받을 수 있습니다 이러한 테스트를 수동으로 실행하는 것 외에도 이러한 태그 기본 설정을 테스트 계획에 저장하는 작업이 새롭게 디자인된 테스트 계획 편집기로 가능합니다 신뢰할 수 있는 핵심 테스트 세트가 있는 새로운 테스트 계획을 만들어 변경 시 발생할 수 있는 버그를 빠르게 포착할 수 있게 했습니다 이 테스트 계획에는 테스트 대상이 모두 포함되어 있습니다 테스트 계획 목록에서 이름인 Core Food를 선택하여 테스트 내비게이터에서 새로운 테스트 계획으로 전환할 수 있습니다 그런 다음 테스트 내비게이터에서 이름을 직접 클릭하여 테스트 계획 편집기를 엽니다 단위 테스트 대상을 확장하여 모든 테스트를 확인합니다 또한 내비게이터를 숨겨서 볼 수 있는 공간을 확보하겠습니다 참고로 테스트 계획은 하나 이상의 테스트 대상을 참조할 수 있으며 테스트 계획 편집기를 사용하면 모든 대상의 테스트를 정리할 수 있습니다
각 모음 및 테스트에 대한 태그는 오른쪽 열에 표시됩니다
포함하거나 제외할 테스트를 선택하려면 테스트 계획 편집기 상단의 이 필드에 태그를 지정합니다 이 테스트 계획을 업데이트해서 모든 핵심 테스트를 실행하되 sesonal 태그가 있는 테스트는 제외하고 싶습니다 이러한 테스트는 연중에 특정한 시기에만 작동할 것으로 예상하는 코드를 다루기 때문에 항상 실행하고 싶지는 않습니다 제외 필드에 해당 태그를 추가해서 seasonal 태그가 있는 테스트를 제외할 수 있습니다
테스트 계획 미리 보기가 변경에 맞춰 자동으로 업데이트됩니다 포함 및 제외 필드에서 현재 활성화되어 있는 태그는 보라색으로 강조 표시됩니다 테스트 계획에서 제외된 태그에는 줄이 그어져 있습니다 제외 필드에 다른 태그를 추가하면 추가 필터링 옵션들이 제공됩니다 테스트 계획이 둘 이상의 태그로 필터링되는 경우 Xcode는 모든 태그 또는 아무 태그 일치에 대한 옵션을 제공합니다 모든 태그가 기본값입니다 이 경우 seasonal 또는 unreleased 태그가 있는 모든 테스트를 제외하기 위해 Any Tag를 사용하겠습니다 이제 두 태그 모두 보라색으로 표시되어 있고 줄이 그어졌습니다 제 테스트 계획에서 적극적으로 제외되고 있기 때문입니다 또한 태그는 전체 테스트 대상 결과 분석에 도움이 되는 도구입니다 다음은 방금 만든 테스트 계획을 실행한 후 생성된 테스트 보고서입니다 실패가 상당히 많은데 테스트 보고서가 태그로 실패를 더 빨리 수정하는 데 도움을 주는 방식을 보겠습니다 테스트 개요 화면을 살펴봅시다 이제 태그가 개요에서 해당 모음과 테스트 옆에 표시됩니다
태그 필터를 사용하여 결과의 범위를 좁힐 수 있습니다 하지만 실패가 너무 많아서 하나씩 살펴보는 것은 지루할 것 같습니다
전체 인사이트 화면으로 가 보면 배포 인사이트를 위한 새로운 섹션이 있습니다 공통적인 실행 대상, 태그, 버그가 있는 테스트 실패의 패턴을 보여주는 섹션입니다 흥미로운 인사이트군요 spicy 태그 관련 모든 테스트가 실패했습니다 인사이트 행을 두 번 클릭하면 세부 정보 화면으로 이동합니다
실패 메시지와 함께 관련된 모든 실패한 테스트가 이 화면에 나타납니다 최근에 비밀 핫소스에 사용하는 칠리 페퍼를 수정해서 모든 spicy 테스트가 실패한 것 같습니다 변경 사항을 검토하고 테스트를 수정하겠습니다
Xcode Cloud도 Swift Testing을 지원하도록 업데이트되었습니다 Xcode에서와 마찬가지로 테스트 모음의 결과를 App Store Connect의 Xcode Cloud 탭에서 볼 수 있습니다 여기에는 테스트에 정의된 특성에 대한 세부 정보가 있습니다 테스트를 정리하고 연결시킬 때 Xcode로 테스트에 영향을 미치는 문제에 대한 인사이트를 얻을 수 있습니다 모음과 태그를 사용하면 더 효율적으로 대규모 테스트 컬렉션을 탐색하고 관리할 수 있습니다 다시 Jonathan이 테스트 병렬 실행을 설명하겠습니다 규모가 크고 관리하기 쉬운 테스트 모음이 생겼으니 병렬 테스트를 통해 테스트를 빠르게 통과하는 방법과 동시 실행 환경에서 테스트를 안정적으로 실행하는 방법을 고려할 차례입니다 병렬 테스트는 Swift Testing에서 기본적으로 활성화되어 있어 추가 코드 없이도 기능을 활용할 수 있습니다 이제 모든 물리적 기기에서 병렬 테스트를 실행할 수 있으므로 더 많은 테스트에 이 모든 이점을 활용할 수 있습니다! 병렬 테스트의 기본 내용부터 살펴보겠습니다 테스트는 직렬 테스트에서 차례대로 실행됩니다 XCTest를 사용해 보신 적이 있다면 테스트가 기본적으로 이렇게 실행됩니다 이는 테스트가 동시에 실행되는 병렬 테스트와는 다릅니다 테스트를 병렬로 실행하면 몇 가지 이점이 있습니다
첫째, 실행 시간이 단축됩니다 1분 1초가 중요한 CI에서 매우 중요하겠죠 이는 결과를 더 빨리 얻을 수 있다는 의미이기도 합니다 Swift Testing에서 함수는 기본적으로 병렬로 실행됩니다 동기식인지 비동기식인지는 관계가 없습니다 이는 XCTest와의 큰 차이점으로 여러 프로세스를 사용하는 병렬화만 지원하여 한 번에 하나의 테스트만 실행하는 것과는 다릅니다 필요하다면 테스트 함수를 MainActor같은 글로벌 액터에 격리할 수 있습니다 다음으로 테스트 실행 순서는 무작위로 지정됩니다 이렇게 하면 테스트 사이에 숨겨진 종속성을 드러내고 조절이 필요한 영역을 노출할 수 있습니다 예를 살펴보겠습니다 두 개의 테스트가 있습니다 첫 번째에서는 컵케이크를 굽고 두 번째 테스트에서는 먹습니다 이 테스트가 항상 순서대로 차례를 지켜 실행되면 두 번째 테스트를 위한 컵케이크가 늘 준비되어 있습니다 첫 번째 테스트에서 구웠으니까요 의도한 게 아니었습니다! 테스트가 병렬로 실행되면 첫 번째에 대한 두 번째의 종속성이 런타임에 노출되어 수정할 수 있습니다 이전 테스트 코드를 변환하는 경우 이러한 종속성 중 일부가 이미 베이크되어 있을 수 있습니다 Swift 6는 기존 코드를 다시 작성할 때 일부 문제 발견에 도움이 될 수 있지만 다른 문제는 찾기 어려울 수 있습니다 첫 번째 단계로 코드를 Swift Testing으로 변환하고 문제는 나중에 돌아와 해결하고 싶을 수 있습니다 아직 모든 문제를 해결하지 못할 수도 있는 경우 .serialized 특성이 도움이 될 수 있습니다 .serialized 특성을 테스트 모음에 추가하면 테스트를 직렬로 실행해야 함을 나타낼 수 있습니다 이러한 테스트는 방금 설명한 이점이 없어지게 되므로 가능하다면 먼저 테스트 코드를 병렬로 실행하도록 리팩터링하는 것을 고려해야 합니다 또한 .serialized는 매개변수화된 테스트 함수에 적용되어 테스트 케이스가 한 번에 하나씩 실행되도록 할 수도 있습니다 다른 모음을 포함하는 모음에 적용하면 자동으로 상속되므로 두 번 추가할 필요가 없습니다 .serialized 특성이 있는 모음의 테스트는 차례대로 실행됩니다 그러나 Swift는 이런 직렬화된 테스트와 관련 없는 다른 테스트를 병렬로 실행할 수 있으므로 병렬 성능을 여전히 활용 가능합니다 필요하다면 테스트를 직렬로 실행할 수 있지만 병렬로 실행할 수 있게 리팩터링하는 것이 좋습니다 Swift Testing을 사용할 때 병렬 테스트가 기본으로 켜져 있어 테스트를 최대한 빠르게 실행할 수 있습니다 또한 Swift 6는 테스트가 병렬로 실행되지 못하게 하는 문제를 찾는 데 도움을 줄 수 있습니다 다음으로 Swift Testing을 사용해 비동기 조건에서 대기하는 기술을 보여드리겠습니다 동시 실행 테스트 코드 작성 시 Swift에서도 프로덕션 코드에서와 같이 동일한 동시 실행 기능을 사용할 수 있습니다 await은 정확히 동일하게 작동하며 작업이 보류되는 동안 다른 테스트 코드가 CPU를 계속 사용할 수 있게 테스트를 일시 중단합니다 일부 코드, 특히 C 또는 Objective-C로 작성된 오래된 코드는 완료 핸들러로 비동기 작업의 끝을 알립니다 이 코드는 테스트 함수가 반환된 후 실행되므로 함수가 성공했는지 확인할 수 없습니다 Swift는 대부분의 완료 핸들러에 대해 대신 사용할 수 있는 비동기 오버로드를 자동으로 제공합니다 테스트 중인 코드가 완료 핸들러를 사용하는데 비동기 오버로드를 사용할 수 없다면 대신 withCheckedContinuation 또는 withCheckedThrowingContinuation으로 기다릴 수 있는 표현식으로 변환할 수 있습니다
Swift의 Continuation에 대한 자세한 내용은 ‘Swift의 async/await 소개’ 세션에서 확인할 수 있습니다
또 다른 콜백은 두 번 이상 실행될 수 있는 이벤트 핸들러입니다 이 버전의 eat 함수는 전체 식사가 끝날 때마다 콜백을 호출하는 것이 아니라 각 쿠키마다 한 번씩 호출합니다 하지만 변수를 사용해 먹은 쿠키의 수를 계산하려고 하면 Swift 6에서 동시 실행 오류가 발생합니다 이렇게 변수를 설정하는 것이 안전하지 않기 때문입니다
테스트 중인 코드가 콜백을 두 번 이상 호출할 수 있고 호출 횟수를 테스트해야 하는 경우 대신 confirmation을 사용할 수 있습니다 기본적으로 confirmation은 정확히 한 번만 발생할 것으로 예상되지만 다른 예상 횟수를 지정할 수도 있습니다 맛있는 쿠키 10개를 구워서 먹고 있으니 이 이벤트가 10번 발생할 겁니다 confirmation이 전혀 발생하지 않아야 한다면 0을 지정할 수도 있습니다 Swift 동시 실행은 프로덕션 코드와 테스트의 강력한 도구입니다 테스트를 병렬로 실행하여 더 빠른 결과를 얻고 async/await, continuations, confirmations를 사용해 테스트 코드가 동시 실행 환경에서 올바르게 실행되도록 할 수 있습니다 이 세션에서는 Swift Testing으로 테스트 작업 흐름 개선 방법을 배웠습니다 광범위한 주제를 다루었으니 간단히 복습해 보겠습니다! 먼저 Swift Testing의 API를 활용하면 표현력 있는 테스트를 작성할 수 있습니다 매개변수화를 사용해 테스트 하나로 다양한 케이스를 실행할 수 있습니다 모음 및 태그와 같은 도구는 테스트 코드 구성과 문서화에 도움이 됩니다 마지막으로, 테스트를 병렬로 수행하면 테스트 실행 시간을 줄일 수 있으며 테스트 사이의 종속성 식별에 도움이 될 수 있습니다 시청해 주셔서 감사합니다 즐거운 테스팅 되세요!
-
-
0:01 - Successful throwing function
// Expecting errors import Testing @Test func brewTeaSuccessfully() throws { let teaLeaves = TeaLeaves(name: "EarlGrey", optimalBrewTime: 4) let cupOfTea = try teaLeaves.brew(forMinutes: 3) }
-
0:02 - Validating a successful throwing function
import Testing @Test func brewTeaSuccessfully() throws { let teaLeaves = TeaLeaves(name: "EarlGrey", optimalBrewTime: 4) let cupOfTea = try teaLeaves.brew(forMinutes: 3) #expect(cupOfTea.quality == .perfect) }
-
0:03 - Validating an error is thrown with do-catch (not recommended)
import Testing @Test func brewTeaError() throws { let teaLeaves = TeaLeaves(name: "EarlGrey", optimalBrewTime: 3) do { try teaLeaves.brew(forMinutes: 100) } catch is BrewingError { // This is the code path we are expecting } catch { Issue.record("Unexpected Error") } }
-
0:04 - Validating a general error is thrown
import Testing @Test func brewTeaError() throws { let teaLeaves = TeaLeaves(name: "EarlGrey", optimalBrewTime: 4) #expect(throws: (any Error).self) { try teaLeaves.brew(forMinutes: 200) // We don't want this to fail the test! } }
-
0:05 - Validating a type of error
import Testing @Test func brewTeaError() throws { let teaLeaves = TeaLeaves(name: "EarlGrey", optimalBrewTime: 4) #expect(throws: BrewingError.self) { try teaLeaves.brew(forMinutes: 200) // We don't want this to fail the test! } }
-
0:06 - Validating a specific error
import Testing @Test func brewTeaError() throws { let teaLeaves = TeaLeaves(name: "EarlGrey", optimalBrewTime: 4) #expect(throws: BrewingError.oversteeped) { try teaLeaves.brew(forMinutes: 200) // We don't want this to fail the test! } }
-
0:07 - Complicated validations
import Testing @Test func brewTea() { let teaLeaves = TeaLeaves(name: "EarlGrey", optimalBrewTime: 4) #expect { try teaLeaves.brew(forMinutes: 3) } throws: { error in guard let error = error as? BrewingError, case let .needsMoreTime(optimalBrewTime) = error else { return false } return optimalBrewTime == 4 } }
-
0:08 - Throwing expectation
import Testing @Test func brewAllGreenTeas() { #expect(throws: BrewingError.self) { brewMultipleTeas(teaLeaves: ["Sencha", "EarlGrey", "Jasmine"], time: 2) } }
-
0:09 - Required expectations
import Testing @Test func brewAllGreenTeas() throws { try #require(throws: BrewingError.self) { brewMultipleTeas(teaLeaves: ["Sencha", "EarlGrey", "Jasmine"], time: 2) } }
-
0:10 - Control flow of validating an optional value (not recommended)
import Testing struct TeaLeaves {symbols let name: String let optimalBrewTime: Int func brew(forMinutes minutes: Int) throws -> Tea { ... } } @Test func brewTea() throws { let teaLeaves = TeaLeaves(name: "Sencha", optimalBrewTime: 2) let brewedTea = try teaLeaves.brew(forMinutes: 100) guard let color = brewedTea.color else { Issue.record("Tea color was not available!") } #expect(color == .green) }
-
0:11 - Failing test with a throwing function
import Testing @Test func softServeIceCreamInCone() throws { try softServeMachine.makeSoftServe(in: .cone) }
-
0:12 - Disabling a test with a throwing function (not recommended)
import Testing @Test(.disabled) func softServeIceCreamInCone() throws { try softServeMachine.makeSoftServe(in: .cone) }
-
0:13 - Wrapping a failing test in withKnownIssue
import Testing @Test func softServeIceCreamInCone() throws { withKnownIssue { try softServeMachine.makeSoftServe(in: .cone) } }
-
0:14 - Wrap just the failing section in withKnownIssue
import Testing @Test func softServeIceCreamInCone() throws { let iceCreamBatter = IceCreamBatter(flavor: .chocolate) try #require(iceCreamBatter != nil) #expect(iceCreamBatter.flavor == .chocolate) withKnownIssue { try softServeMachine.makeSoftServe(in: .cone) } }
-
0:15 - Simple enumerations
import Testing enum SoftServe { case vanilla, chocolate, pineapple }
-
0:16 - Complex types
import Testing struct SoftServe { let flavor: Flavor let container: Container let toppings: [Topping] } @Test(arguments: [ SoftServe(flavor: .vanilla, container: .cone, toppings: [.sprinkles]), SoftServe(flavor: .chocolate, container: .cone, toppings: [.sprinkles]), SoftServe(flavor: .pineapple, container: .cup, toppings: [.whippedCream]) ]) func softServeFlavors(_ softServe: SoftServe) { /*...*/ }
-
0:17 - Conforming to CustomTestStringConvertible
import Testing struct SoftServe: CustomTestStringConvertible { let flavor: Flavor let container: Container let toppings: [Topping] var testDescription: String { "\(flavor) in a \(container)" } } @Test(arguments: [ SoftServe(flavor: .vanilla, container: .cone, toppings: [.sprinkles]), SoftServe(flavor: .chocolate, container: .cone, toppings: [.sprinkles]), SoftServe(flavor: .pineapple, container: .cup, toppings: [.whippedCream]) ]) func softServeFlavors(_ softServe: SoftServe) { /*...*/ }
-
0:18 - An enumeration with a computed property
extension IceCream { enum Flavor { case vanilla, chocolate, strawberry, mintChip, rockyRoad, pistachio var containsNuts: Bool { switch self { case .rockyRoad, .pistachio: return true default: return false } } } }
-
0:19 - A test function for a specific case of an enumeration
import Testing @Test func doesVanillaContainNuts() throws { try #require(!IceCream.Flavor.vanilla.containsNuts) }
-
0:20 - Separate test functions for all cases of an enumeration
import Testing @Test func doesVanillaContainNuts() throws { try #require(!IceCream.Flavor.vanilla.containsNuts) } @Test func doesChocolateContainNuts() throws { try #require(!IceCream.Flavor.chocolate.containsNuts) } @Test func doesStrawberryContainNuts() throws { try #require(!IceCream.Flavor.strawberry.containsNuts) } @Test func doesMintChipContainNuts() throws { try #require(!IceCream.Flavor.mintChip.containsNuts) } @Test func doesRockyRoadContainNuts() throws { try #require(!IceCream.Flavor.rockyRoad.containsNuts) }
-
0:21 - Parameterizing a test with a for loop (not recommended)
import Testing extension IceCream { enum Flavor { case vanilla, chocolate, strawberry, mintChip, rockyRoad, pistachio } } @Test func doesNotContainNuts() throws { for flavor in [IceCream.Flavor.vanilla, .chocolate, .strawberry, .mintChip] { try #require(!flavor.containsNuts) } }
-
0:22 - Swift testing parameterized tests
import Testing extension IceCream { enum Flavor { case vanilla, chocolate, strawberry, mintChip, rockyRoad, pistachio } } @Test(arguments: [IceCream.Flavor.vanilla, .chocolate, .strawberry, .mintChip]) func doesNotContainNuts(flavor: IceCream.Flavor) throws { try #require(!flavor.containsNuts) }
-
0:23 - 100% test coverage
import Testing extension IceCream { enum Flavor { case vanilla, chocolate, strawberry, mintChip, rockyRoad, pistachio } } @Test(arguments: [IceCream.Flavor.vanilla, .chocolate, .strawberry, .mintChip]) func doesNotContainNuts(flavor: IceCream.Flavor) throws { try #require(!flavor.containsNuts) } @Test(arguments: [IceCream.Flavor.rockyRoad, .pistachio]) func containNuts(flavor: IceCream.Flavor) { #expect(flavor.containsNuts) }
-
0:24 - A parameterized test with one argument
import Testing enum Ingredient: CaseIterable { case rice, potato, lettuce, egg } @Test(arguments: Ingredient.allCases) func cook(_ ingredient: Ingredient) async throws { #expect(ingredient.isFresh) let result = try cook(ingredient) try #require(result.isDelicious) }
-
0:26 - Adding a second argument to a parameterized test
import Testing enum Ingredient: CaseIterable { case rice, potato, lettuce, egg } enum Dish: CaseIterable { case onigiri, fries, salad, omelette } @Test(arguments: Ingredient.allCases, Dish.allCases) func cook(_ ingredient: Ingredient, into dish: Dish) async throws { #expect(ingredient.isFresh) let result = try cook(ingredient) try #require(result.isDelicious) try #require(result == dish) }
-
0:28 - Using zip() on arguments
import Testing enum Ingredient: CaseIterable { case rice, potato, lettuce, egg } enum Dish: CaseIterable { case onigiri, fries, salad, omelette } @Test(arguments: zip(Ingredient.allCases, Dish.allCases)) func cook(_ ingredient: Ingredient, into dish: Dish) async throws { #expect(ingredient.isFresh) let result = try cook(ingredient) try #require(result.isDelicious) try #require(result == dish) }
-
0:29 - Suites
@Suite("Various desserts") struct DessertTests { @Test func applePieCrustLayers() { /* ... */ } @Test func lavaCakeBakingTime() { /* ... */ } @Test func eggWaffleFlavors() { /* ... */ } @Test func cheesecakeBakingStrategy() { /* ... */ } @Test func mangoSagoToppings() { /* ... */ } @Test func bananaSplitMinimumScoop() { /* ... */ } }
-
0:30 - Nested suites
import Testing @Suite("Various desserts") struct DessertTests { @Suite struct WarmDesserts { @Test func applePieCrustLayers() { /* ... */ } @Test func lavaCakeBakingTime() { /* ... */ } @Test func eggWaffleFlavors() { /* ... */ } } @Suite struct ColdDesserts { @Test func cheesecakeBakingStrategy() { /* ... */ } @Test func mangoSagoToppings() { /* ... */ } @Test func bananaSplitMinimumScoop() { /* ... */ } } }
-
0:31 - Separate suites
@Suite struct DrinkTests { @Test func espressoExtractionTime() { /* ... */ } @Test func greenTeaBrewTime() { /* ... */ } @Test func mochaIngredientProportion() { /* ... */ } } @Suite struct DessertTests { @Test func espressoBrownieTexture() { /* ... */ } @Test func bungeoppangFilling() { /* ... */ } @Test func fruitMochiFlavors() { /* ... */ } }
-
0:32 - Separate suites
@Suite struct DrinkTests { @Test func espressoExtractionTime() { /* ... */ } @Test func greenTeaBrewTime() { /* ... */ } @Test func mochaIngredientProportion() { /* ... */ } } @Suite struct DessertTests { @Test func espressoBrownieTexture() { /* ... */ } @Test func bungeoppangFilling() { /* ... */ } @Test func fruitMochiFlavors() { /* ... */ } }
-
0:35 - Using a tag
import Testing extension Tag { @Tag static var caffeinated: Self } @Suite(.tags(.caffeinated)) struct DrinkTests { @Test func espressoExtractionTime() { /* ... */ } @Test func greenTeaBrewTime() { /* ... */ } @Test func mochaIngredientProportion() { /* ... */ } } @Suite struct DessertTests { @Test(.tags(.caffeinated)) func espressoBrownieTexture() { /* ... */ } @Test func bungeoppangFilling() { /* ... */ } @Test func fruitMochiFlavors() { /* ... */ } }
-
0:36 - Declare and use a second tag
import Testing extension Tag { @Tag static var caffeinated: Self @Tag static var chocolatey: Self } @Suite(.tags(.caffeinated)) struct DrinkTests { @Test func espressoExtractionTime() { /* ... */ } @Test func greenTeaBrewTime() { /* ... */ } @Test(.tags(.chocolatey)) func mochaIngredientProportion() { /* ... */ } } @Suite struct DessertTests { @Test(.tags(.caffeinated, .chocolatey)) func espressoBrownieTexture() { /* ... */ } @Test func bungeoppangFilling() { /* ... */ } @Test func fruitMochiFlavors() { /* ... */ } }
-
0:37 - Two tests with an unintended data dependency (not recommended)
import Testing // ❌ This code is not concurrency-safe. var cupcake: Cupcake? = nil @Test func bakeCupcake() async { cupcake = await Cupcake.bake(toppedWith: .frosting) // ... } @Test func eatCupcake() async { await eat(cupcake!) // ... }
-
0:38 - Serialized trait
import Testing @Suite("Cupcake tests", .serialized) struct CupcakeTests { var cupcake: Cupcake? @Test func mixingIngredients() { /* ... */ } @Test func baking() { /* ... */ } @Test func decorating() { /* ... */ } @Test func eating() { /* ... */ } }
-
0:39 - Serialized trait with nested suites
import Testing @Suite("Cupcake tests", .serialized) struct CupcakeTests { var cupcake: Cupcake? @Suite("Mini birthday cupcake tests") struct MiniBirthdayCupcakeTests { // ... } @Test(arguments: [...]) func mixing(ingredient: Food) { /* ... */ } @Test func baking() { /* ... */ } @Test func decorating() { /* ... */ } @Test func eating() { /* ... */ } }
-
0:40 - Using async/await in a test
import Testing @Test func bakeCookies() async throws { let cookies = await Cookie.bake(count: 10) try await eat(cookies, with: .milk) }
-
0:41 - Using a function with a completion handler in a test (not recommended)
import Testing @Test func bakeCookies() async throws { let cookies = await Cookie.bake(count: 10) // ❌ This code will run after the test function returns. eat(cookies, with: .milk) { result, error in #expect(result != nil) } }
-
0:42 - Replacing a completion handler with an asynchronous function call
import Testing @Test func bakeCookies() async throws { let cookies = await Cookie.bake(count: 10) try await eat(cookies, with: .milk) }
-
0:43 - Using withCheckedThrowingContinuation
import Testing @Test func bakeCookies() async throws { let cookies = await Cookie.bake(count: 10) try await withCheckedThrowingContinuation { continuation in eat(cookies, with: .milk) { result, error in if let result { continuation.resume(returning: result) } else { continuation.resume(throwing: error) } } } }
-
0:44 - Callback that invokes more than once (not recommended)
import Testing @Test func bakeCookies() async throws { let cookies = await Cookie.bake(count: 10) // ❌ This code is not concurrency-safe. var cookiesEaten = 0 try await eat(cookies, with: .milk) { cookie, crumbs in #expect(!crumbs.in(.milk)) cookiesEaten += 1 } #expect(cookiesEaten == 10) }
-
0:45 - Confirmations on callbacks that invoke more than once
import Testing @Test func bakeCookies() async throws { let cookies = await Cookie.bake(count: 10) try await confirmation("Ate cookies", expectedCount: 10) { ateCookie in try await eat(cookies, with: .milk) { cookie, crumbs in #expect(!crumbs.in(.milk)) ateCookie() } } }
-
0:46 - Confirmation that occurs 0 times
import Testing @Test func bakeCookies() async throws { let cookies = await Cookie.bake(count: 10) try await confirmation("Ate cookies", expectedCount: 0) { ateCookie in try await eat(cookies, with: .milk) { cookie, crumbs in #expect(!crumbs.in(.milk)) ateCookie() } } }
-
-
찾고 계신 콘텐츠가 있나요? 위에 주제를 입력하고 원하는 내용을 바로 검색해 보세요.
쿼리를 제출하는 중에 오류가 발생했습니다. 인터넷 연결을 확인하고 다시 시도해 주세요.