Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Beyond JUnit: Pragmatic Ways to Increase Code ...

Beyond JUnit: Pragmatic Ways to Increase Code Quality

You and your team are writing and running unit tests - great! Better yet, they pass (most of the time)! But can you do more to ensure the quality of your code? Come to this talk where you will learn some practical new skills to help increase the quality of your code and catch bugs early in the development cycle. You'll learn how ArchUnit can help you write tests for your application architecture, and how TestCcontainers can help ease your integration testing burden. At the end of this talk, you will know several new techniques to bring to your codebase that will ultimately make your customers happy. This talk is aimed at Java developers who have some basic testing knowledge and want to move to the next level.

Todd Ginsberg

August 08, 2022
Tweet

More Decks by Todd Ginsberg

Other Decks in Technology

Transcript

  1. @ToddGinsberg Beyond JUnit: Pragmatic Ways to Increase Code Quality “Dealing

    with things sensibly and realistically in a way that is based on practical rather than theoretical considerations.” (Thanks, Google!)
  2. @ToddGinsberg I Am Making Some Assumptions About You… • You

    already write some kind of tests using a modern framework like JUnit 5.
  3. @ToddGinsberg I Am Making Some Assumptions About You… • You

    already write some kind of tests using a modern framework like JUnit 5. • You have covered the basics of code quality with static analysis tools, and maybe an enforced coding style.
  4. @ToddGinsberg What You Will See In This Talk 1) How

    to test the architecture of your code.
  5. @ToddGinsberg What You Will See In This Talk 1) How

    to test the architecture of your code. 2) How to test that your code plays well with others.
  6. @ToddGinsberg Any fool can write code that a computer can

    understand. Good programmers write code that humans can understand. – Martin Fowler Refactoring: Improving the Design of Existing Code
  7. @ToddGinsberg Any fool can write code that a computer can

    understand. Good programmers write code that humans can understand. – Martin Fowler Refactoring: Improving the Design of Existing Code
  8. @ToddGinsberg What’s the Problem? API Layer Service Layer Data Layer

    Controller Service Controller Service Repository Repository
  9. @ToddGinsberg What’s the Problem? API Layer Service Layer Data Layer

    Controller Service Controller Service Repository Repository
  10. @ToddGinsberg What’s the Problem? API Layer Service Layer Data Layer

    Controller Service Controller Service Repository Repository
  11. @ToddGinsberg What’s the Problem? API Layer Service Layer Data Layer

    Controller Service Controller Service Repository Repository
  12. @ToddGinsberg Our First Architectural Test… @Test public void implNamingRule() {

    JavaClasses classes = // TODO ArchRule theRule = // TODO }
  13. @ToddGinsberg Our First Architectural Test… @Test public void implNamingRule() {

    JavaClasses classes = // TODO ArchRule theRule = // TODO theRule.check(classes); }
  14. @ToddGinsberg Our First Architectural Test… @Test public void implNamingRule() {

    JavaClasses classes = new ClassFileImporter() .importPackages( "com.example.donutapi" ); }
  15. @ToddGinsberg Our First Architectural Test… @Test public void implNamingRule() {

    JavaClasses classes = new ClassFileImporter() .importPackages( "com.example.donutapi", "com.example.utilities" ); }
  16. @ToddGinsberg Our First Architectural Test… @Test public void implNamingRule() {

    JavaClasses classes = new ClassFileImporter() .importPackages("com.example.donutapi"); ArchRule theRule = theRule.check(classes); }
  17. @ToddGinsberg Our First Architectural Test… @Test public void implNamingRule() {

    JavaClasses classes = new ClassFileImporter() .importPackages("com.example.donutapi"); ArchRule theRule = noClasses() theRule.check(classes); }
  18. @ToddGinsberg Our First Architectural Test… @Test public void implNamingRule() {

    JavaClasses classes = new ClassFileImporter() .importPackages("com.example.donutapi"); ArchRule theRule = noClasses() .should() theRule.check(classes); }
  19. @ToddGinsberg Our First Architectural Test… @Test public void implNamingRule() {

    JavaClasses classes = new ClassFileImporter() .importPackages("com.example.donutapi"); ArchRule theRule = noClasses() .should() .haveNameMatching(".*Impl"); theRule.check(classes); }
  20. @ToddGinsberg Our First Architectural Test… ArchRule theRule = noClasses() .should()

    .haveNameMatching(".*Impl"); Architecture Violation [Priority: MEDIUM] - Rule 'no classes should have name matching '.*Impl'' was violated (1 times): Class <com.example.donutapi.service.SomeServiceImpl> matches '.*Impl' in (SomeServiceImpl.java:0)
  21. @ToddGinsberg Our First Architectural Test… ArchRule theRule = noClasses() .should()

    .haveNameMatching(".*Impl"); Architecture Violation [Priority: MEDIUM] - Rule 'no classes should have name matching '.*Impl'' was violated (1 times): Class <com.example.donutapi.service.SomeServiceImpl> matches '.*Impl' in (SomeServiceImpl.java:0)
  22. @ToddGinsberg Our First Architectural Test… ArchRule theRule = noClasses() .should()

    .haveNameMatching(".*Impl"); Architecture Violation [Priority: MEDIUM] - Rule 'no classes should have name matching '.*Impl'' was violated (1 times): Class <com.example.donutapi.service.SomeServiceImpl> matches '.*Impl' in (SomeServiceImpl.java:0)
  23. @ToddGinsberg Our First Architectural Test… ArchRule theRule = noClasses() .should()

    .haveNameMatching(".*Impl"); Architecture Violation [Priority: MEDIUM] - Rule 'no classes should have name matching '.*Impl'' was violated (1 times): Class <com.example.donutapi.service.SomeServiceImpl> matches '.*Impl' in (SomeServiceImpl.java:0)
  24. @ToddGinsberg Our First Architectural Test… ArchRule theRule = noClasses() .should()

    .haveNameMatching(".*Impl") .because("it doesn't tell us anything useful");
  25. @ToddGinsberg Our First Architectural Test… ArchRule theRule = noClasses() .should()

    .haveNameMatching(".*Impl") .because("it doesn't tell us anything useful"); Architecture Violation [Priority: MEDIUM] - Rule 'no classes should have name matching '.*Impl', because it doesn't tell us anything useful' was violated (1 times): Class <com.example.donutapi.service.SomeServiceImpl> matches '.*Impl' in (SomeServiceImpl.java:0)
  26. @ToddGinsberg Our First Architectural Test (Simplified) @AnalyzeClasses( packages = {"com.example.donutapi"},

    importOptions = {ImportOption.DoNotIncludeTests.class} ) public class ExampleTest { @ArchTest static final ArchRule implNaming = noClasses() .should() .haveNameMatching(".*Impl") .because("it doesn't tell us anything useful"); }
  27. @ToddGinsberg Our First Architectural Test (Simplified) @AnalyzeClasses( packages = {"com.example.donutapi"},

    importOptions = {ImportOption.DoNotIncludeTests.class} ) public class ExampleTest { @ArchTest static final ArchRule implNaming = noClasses() .should() .haveNameMatching(".*Impl") .because("it doesn't tell us anything useful"); }
  28. @ToddGinsberg Our First Architectural Test (Simplified) @AnalyzeClasses( packages = {"com.example.donutapi"},

    importOptions = {ImportOption.DoNotIncludeTests.class} ) public class ExampleTest { @ArchTest static final ArchRule implNaming = noClasses() .should() .haveNameMatching(".*Impl") .because("it doesn't tell us anything useful"); }
  29. @ToddGinsberg Our First Architectural Test (Simplified) @AnalyzeClasses( packages = {"com.example.donutapi"},

    importOptions = {ImportOption.DoNotIncludeTests.class} ) public class ExampleTest { @ArchTest static final ArchRule implNaming = noClasses() .should() .haveNameMatching(".*Impl") .because("it doesn't tell us anything useful"); }
  30. @ToddGinsberg Our First Architectural Test (Simplified) @AnalyzeClasses( packages = {"com.example.donutapi"},

    importOptions = {ImportOption.DoNotIncludeTests.class} ) public class ExampleTest { @ArchTest static final ArchRule implNaming = noClasses() .should() .haveNameMatching(".*Impl") .because("it doesn't tell us anything useful"); }
  31. @ToddGinsberg Our First Architectural Test (Simplified) @AnalyzeClasses( packages = {"com.example.donutapi"},

    importOptions = {ImportOption.DoNotIncludeTests.class} ) public class ExampleTest { @ArchTest static final ArchRule implNaming = noClasses() .should() .haveNameMatching(".*Impl") .because("it doesn't tell us anything useful"); }
  32. @ToddGinsberg Our First Architectural Test (Simplified) @ArchTest static final ArchRule

    implNaming = noClasses() .should() .haveNameMatching(".*Impl") .because("it doesn't tell us anything useful");
  33. @ToddGinsberg Use Case: Prohibit Dependencies Architecture Violation [Priority: MEDIUM] -

    Rule 'no classes should depend on classes that reside in any package ['org.apache.log4j', 'org.apache.logging.log4j'], because our standard is logback' was violated (2 times):
  34. @ToddGinsberg Simplify Violation Messages Architecture Violation [Priority: MEDIUM] - Rule

    'no classes should depend on classes that reside in any package ['org.apache.log4j', 'org.apache.logging.log4j'], because our standard is logback' was violated (2 times):
  35. @ToddGinsberg Simplify Violation Messages Architecture Violation [Priority: MEDIUM] - Rule

    'stop using log4j, because our standard is logback' was violated (2 times):
  36. @ToddGinsberg Simplify Violation Messages Architecture Violation [Priority: MEDIUM] - Rule

    'stop using log4j, because our standard is logback' was violated (2 times):
  37. @ToddGinsberg Parts of a Rule classes() .that() .areAnnotatedWith(Controller.class) .or().areAnnotatedWith(RestController.class) .should()

    .resideInAPackage("..controller..") .because("controllers belong in a controller package");
  38. @ToddGinsberg Parts of a Rule classes() .that() .areAnnotatedWith(Controller.class) .or().areAnnotatedWith(RestController.class) .should()

    .resideInAPackage("..controller..") .because("controllers belong in a controller package");
  39. @ToddGinsberg Parts of a Rule classes() .that() .areAnnotatedWith(Controller.class) .or().areAnnotatedWith(RestController.class) .should()

    .resideInAPackage("..controller..") .because("controllers belong in a controller package");
  40. @ToddGinsberg Class Predicates areAnnotatedWith areAnnotations areAnonymousClasses areAssignableFrom areAssignableTo areEnums areInnerClasses

    areInterfaces areLocalClasses areMemberClasses areMetaAnnotatedWith areNestedClasses areNotAnnotatedWith areNotAnnotations areNotAnonymousClasses areNotAssignableFrom areNotAssignableTo areNotEnums areNotInnerClasses areNotInterfaces areNotLocalClasses areNotMemberClasses areNotMetaAnnotatedWith areNotNestedClasses areNotPackagePrivate areNotPrivate areNotProtected areNotPublic areNotRecords areNotTopLevelClasses arePackagePrivate arePrivate
  41. @ToddGinsberg Class Predicates areProtected arePublic areRecords areTopLevelClasses belongToAnyOf containAnyCodeUnitsThat containAnyConstructorsThat

    containAnyFieldsThat containAnyMembersThat containAnyMethodsThat containAnyStaticInitializersThat doNotBelongToAnyOf doNotHaveFullyQualifiedName doNotHaveModifier doNotHaveSimpleName doNotImplement haveFullyQualifiedName haveModifier haveNameMatching haveNameNotMatching haveSimpleName haveSimpleNameContaining haveSimpleNameEndingWith haveSimpleNameNotContaining haveSimpleNameNotEndingWith haveSimpleNameNotStartingWith haveSimpleNameStartingWith implement resideInAPackage resideInAnyPackage resideOutsideOfPackage resideOutsideOfPackages
  42. @ToddGinsberg Parts of a Rule classes() .that() .areAnnotatedWith(Controller.class) .or().areAnnotatedWith(RestController.class) .should()

    .resideInAPackage("..controller..") .because("controllers belong in a controller package");
  43. @ToddGinsberg Class Conditions accessClassesThat accessField accessFieldWhere accessTargetWhere be beAnnotatedWith beAnonymousClasses

    beAssignableFrom beAssignableTo beEnums beInnerClasses beInterfaces beLocalClasses beMemberClasses beMetaAnnotatedWith beNestedClasses bePackagePrivate bePrivate beProtected bePublic beRecords beTopLevelClasses callCodeUnitWhere callConstructor callConstructorWhere callMethod callMethodWhere containNumberOfElements dependOnClassesThat getField getFieldWhere haveFullyQualifiedName
  44. @ToddGinsberg Class Conditions haveModifier haveNameMatching haveNameNotMatching haveOnlyFinalFields haveOnlyPrivateConstructors haveSimpleName haveSimpleNameContaining

    haveSimpleNameEndingWith haveSimpleNameNotContaining haveSimpleNameNotEndingWith haveSimpleNameNotStartingWith haveSimpleNameStartingWith implement notBe notBeAnnotatedWith notBeAnonymousClasses notBeAssignableFrom notBeAssignableTo notBeEnums notBeInnerClasses notBeInterfaces notBeLocalClasses notBeMemberClasses notBeMetaAnnotatedWith notBeNestedClasses notBePackagePrivate notBePrivate notBeProtected notBePublic notBeRecords
  45. @ToddGinsberg Class Conditions notBeRecords notBeTopLevelClasses notHaveFullyQualifiedName notHaveModifier notHaveSimpleName notImplement onlyAccessClassesThat

    onlyAccessFieldsThat onlyAccessMembersThat onlyCallCodeUnitsThat onlyCallConstructorsThat onlyCallMethodsThat onlyDependOnClassesThat onlyHaveDependentClassesThat resideInAnyPackage resideInAPackage resideOutsideOfPackage resideOutsideOfPackages setField setFieldWhere transitivelyDependOnClassesThat
  46. @ToddGinsberg Use Case: Layered Architecture API Layer Service Layer Data

    Layer Controller Service Controller Service Repository Repository
  47. @ToddGinsberg Use Case: Layered Architecture The hard way… noClasses() .that()

    .resideOutsideOfPackage("..controller..") .should() .accessClassesThat() .resideInAnyPackage("..controller..");
  48. @ToddGinsberg Use Case: Layered Architecture The hard way… classes() .that()

    .resideInAnyPackage("..service..") .should() .onlyBeAccessed() .byAnyPackage("..controller..");
  49. @ToddGinsberg Use Case: Layered Architecture The easy way… layeredArchitecture() .layer("Controller").definedBy("..controller..")

    .layer("Service").definedBy("..service..") .layer("Data").definedBy("..data..") .whereLayer("Controller").mayNotBeAccessedByAnyLayer() .whereLayer("Service").mayOnlyBeAccessedByLayers("Controller") .whereLayer("Data").mayOnlyBeAccessedByLayers("Service");
  50. @ToddGinsberg Use Case: Onion Architecture The easy way… onionArchitecture() .domainModels("..model..")

    .domainServices("..service..") .applicationServices("..app..") .adapter("cli", "..cli..") .adapter("data", "..data..") .adapter("rest", "..rest..");
  51. @ToddGinsberg Use Case: PlantUML Diagram @startuml [Address] <<..address..>> [Customer] <<..customer..>>

    [Order] <<..order..>> #white/PowderBlue [Products] <<..product..>> #white/PowderBlue [Product Catalog] <<..catalog..>> as catalog #PowderBlue [Product Import] <<..importer..>> as import [XML] <<..xml.processor..>> <<..xml.types..>> as xml [Order] ---> [Customer] : is placed by [Order] --> [Products] [Customer] --> [Address] [Products] <--[#green]- catalog import -left-> catalog : parse products import --> xml @enduml
  52. @ToddGinsberg Use Case: PlantUML Diagrams URL myDiagram = getClass().getResource("project.puml"); classes()

    .should( adhereToPlantUmlDiagram( myDiagram, consideringAllDependencies() ) );
  53. @ToddGinsberg ArchUnit-Provided Rules • Don’t use standard streams (System.out, System.in,

    printStackTrace) • Don’t throw generic exceptions (Throwable, Exception, RuntimeException) • Don’t use java.util.logging • Don’t use Joda Time • Prevent field-level autowiring (@Autowired, @Value, @Inject, @Resource, etc)
  54. @ToddGinsberg What About Legacy Applications? This is a README. Pay

    attention to it! This repository is not a place of honor... no highly esteemed code is kept here. What is here was dangerous and repulsive to us. This repository is best shunned and left untouched.
  55. @ToddGinsberg Use Case: Freezing Rules FreezingArchRule.freeze( layeredArchitecture() .layer("Controller").definedBy("..controller..") .layer("Service").definedBy("..service..") .layer("Data").definedBy("..data..")

    .whereLayer("Controller").mayNotBeAccessedByAnyLayer() .whereLayer("Service").mayOnlyBeAccessedByLayers("Controller") .whereLayer("Data").mayOnlyBeAccessedByLayers("Service") );
  56. @ToddGinsberg Testing Our Rules classes() .that() .resideInAnyPackage("that doesn’t exist") .should()

    // ... This rule will FAIL if should() operates on an empty set of classes!
  57. @ToddGinsberg Testing Our Rules ArchRule auroraBorealisLocationAndTimeOfDay = //... JavaClasses classes

    = new ClassFileImporter().importClasses(SkinnersKitchen.class); auroraBorealisLocationAndTimeOfDay.check(classes)
  58. @ToddGinsberg Testing Our Rules ArchRule auroraBorealisLocationAndTimeOfDay = //... JavaClasses classes

    = new ClassFileImporter().importClasses(SkinnersKitchen.class); assertThatThrownBy( () -> auroraBorealisLocationAndTimeOfDay.check(classes) ) .isInstanceOf(AssertionError.class) .hasMessageStartingWith("Architecture Violation");
  59. @ToddGinsberg Why Use ArchUnit? • Quickly enforce architectural rules from

    within our project. • Runs as part of test cycle for early, inescapable feedback.
  60. @ToddGinsberg Why Use ArchUnit? • Quickly enforce architectural rules from

    within our project. • Runs as part of test cycle for early, inescapable feedback. • Documents intent.
  61. @ToddGinsberg Why Use ArchUnit? • Quickly enforce architectural rules from

    within our project. • Runs as part of test cycle for early, inescapable feedback. • Documents intent. • Helps move legacy systems towards enforced standards.
  62. @ToddGinsberg Adding To Our Project <dependency> <groupId>org.testcontainers</groupId> <artifactId>testcontainers</artifactId> <version>1.17.3</version> <scope>test</scope>

    </dependency> <dependency> <groupId>org.testcontainers</groupId> <artifactId>junit-jupiter</artifactId> <version>1.17.3</version> <scope>test</scope> </dependency>
  63. @ToddGinsberg Use Case: Database Tests @Autowired private DonutRepository repository; @Container

    private static PostgreSQLContainer database = new PostgreSQLContainer("postgres:14.2");
  64. @ToddGinsberg Use Case: Database Tests @Autowired private DonutRepository repository; @Container

    private static PostgreSQLContainer database = new PostgreSQLContainer("postgres:14.2"); @DynamicPropertySource public static void props(DynamicPropertyRegistry registry) { registry.add("spring.datasource.url", database::getJdbcUrl); registry.add("spring.datasource.username", database::getUsername); registry.add("spring.datasource.password", database::getPassword); }
  65. @ToddGinsberg Use Case: Database Tests @Autowired private DonutRepository repository; @Container

    private static PostgreSQLContainer database = new PostgreSQLContainer("postgres:14.2") .withDatabaseName("setec_astronomy") .withUsername("mbishop") .withPassword("T00ManyS3crets");
  66. @ToddGinsberg Use Case: Database Tests @Autowired private DonutRepository repository; @Container

    private static PostgreSQLContainer database = new PostgreSQLContainer("postgres:14.2") .withConnectionTimeoutSeconds(20) .withStartupTimeoutSeconds(20);
  67. @ToddGinsberg Use Case: Database Tests @Autowired private DonutRepository repository; @Container

    private static PostgreSQLContainer database = new PostgreSQLContainer("postgres:14.2") .withInitScript("src/init.sql");
  68. @ToddGinsberg Supported Database Modules JDBC R2DBC Cassandra CockroachDB Clickhouse DB2

    Dynalite Influx MariaDB MongoDB MS SQL Server MySQL Neo4j Oracle-XE OrientDB Postgres Presto Trinio
  69. @ToddGinsberg Supported Database Modules JDBC R2DBC Cassandra CockroachDB Clickhouse DB2

    Dynalite Influx MariaDB MongoDB MS SQL Server MySQL Neo4j Oracle-XE OrientDB Postgres Presto Trinio
  70. @ToddGinsberg Use Case: Database Tests (But Simpler!) @SpringBootTest public class

    JdbcUrlTest { static { System.setProperty( "spring.datasource.url", "jdbc:tc:postgresql:14.2://localhost:5432/somedb" ); } }
  71. @ToddGinsberg Use Case: Database Tests (But Simpler!) @SpringBootTest public class

    JdbcUrlTest { static { System.setProperty( "spring.datasource.url", "jdbc:tc:postgresql:14.2://localhost:5432/somedb" ); } }
  72. @ToddGinsberg Use Case: Database Tests (But Simpler!) @SpringBootTest public class

    JdbcUrlTest { static { System.setProperty( "spring.datasource.url", "jdbc:tc:postgresql:14.2://localhost:5432/" ); } }
  73. @ToddGinsberg Use Case: Database Tests (But Simpler!) @SpringBootTest public class

    JdbcUrlTest { static { System.setProperty( "spring.datasource.url", "jdbc:tc:postgresql:14.2://localhost:5432/" ); } }
  74. @ToddGinsberg Use Case: Database Tests (But Simpler!) @SpringBootTest public class

    JdbcUrlTest { static { System.setProperty( "spring.datasource.url", "jdbc:tc:postgresql:14.2:///" ); } }
  75. @ToddGinsberg JDBC Init Script Options… jdbc:tc:postgresql:14.2:/// ?TC_INITFUNCTION=JdbcTest::initFunction public class JdbcTest

    { public static void initFunction(Connection connection) throws SQLException { // Flyway, Liquibase, etc... } }
  76. @ToddGinsberg Use Case: Database Tests (But Simpler!) @SpringBootTest public class

    JdbcUrlTest { static { System.setProperty( "spring.datasource.driverClassName", "org.testcontainers.jdbc.ContainerDatabaseDriver" ); System.setProperty( "spring.datasource.url", "jdbc:tc:postgresql:14.2:///" ); } }
  77. @ToddGinsberg Use Case: Database Tests (But Simpler!) @SpringBootTest public class

    JdbcUrlTest { static { System.setProperty( "spring.datasource.driverClassName", "org.testcontainers.jdbc.ContainerDatabaseDriver" ); System.setProperty( "spring.datasource.url", "jdbc:tc:postgresql:14.2:///" ); } }
  78. @ToddGinsberg In A Base Class For All Tests public abstract

    class AbstractBaseTest { static PostgreSQLContainer database; static GenericContainer redis; static { database = new PostgreSQLContainer("postgres:14.2"); database.start(); redis = new GenericContainer("redis:6.4.2-alpine"); redis.start(); } }
  79. @ToddGinsberg More Supported Modules Azure Docker Compose ElasticSearch GCloud HiveMQ

    K3S Kafka LocalStack MockServer Nginx Apache Pulsar RabbitMQ Solr Toxiproxy Hashicorp Vault WebDriver
  80. @ToddGinsberg More Supported Modules Azure Docker Compose ElasticSearch GCloud HiveMQ

    K3S Kafka LocalStack MockServer Nginx Apache Pulsar RabbitMQ Solr Toxiproxy Hashicorp Vault WebDriver
  81. @ToddGinsberg More Supported Modules Azure Docker Compose ElasticSearch GCloud HiveMQ

    K3S Kafka LocalStack MockServer Nginx Apache Pulsar RabbitMQ Solr Toxiproxy Hashicorp Vault WebDriver
  82. @ToddGinsberg More Supported Modules Azure Docker Compose ElasticSearch GCloud HiveMQ

    K3S Kafka LocalStack MockServer Nginx Apache Pulsar RabbitMQ Solr Toxiproxy Hashicorp Vault WebDriver
  83. @ToddGinsberg Why Use Testcontainers? • Isolated environments against real components

    • App start + DB created in a single test • Easier to test failure cases
  84. @ToddGinsberg Why Use Testcontainers? • Isolated environments against real components

    • App start + DB created in a single test • Easier to test failure cases • Run tests offline*
  85. @ToddGinsberg Why Use Testcontainers? • Isolated environments against real components

    • App start + DB created in a single test • Easier to test failure cases • Run tests offline* • Code and Integration Tests in the same PR and repo
  86. @ToddGinsberg More On Testcontainers! Removing complexity from integration tests using

    Testcontainers! By Sergei Egorov Tuesday @1:00pm Room 2214 (DevOps Track)