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

GeeCON 2019: Testcontainers: a year-in-review

GeeCON 2019: Testcontainers: a year-in-review

Unit testing is fine, but without proper integration testing, especially if you work with external resources like databases and other services, you might not know how your application will actually behave once it has been deployed to the real production environment.

Testcontainers is a popular JVM testing library that provides lightweight, throwaway instances of common databases, Selenium web browsers, or anything else that can run in a Docker container.

In this talk, we will briefly go throught the past, present and the future of the library.

Sergei Egorov

May 17, 2019
Tweet

More Decks by Sergei Egorov

Other Decks in Programming

Transcript

  1. Testcontainers:

    a year-in-review
    Sergei Egorov
    Kraków, 15-17 May 2019
    @bsideup

    View Slide

  2. Top ques!ons (0) Room 11
    Join at
    slido.com
    #geecon2019
    Join at
    #geecon2019
    Room 11
    slido com
    Ask questions at

    slido.com

    #geecon2019

    room 1

    View Slide

  3. Tweet photos

    #geecon

    @bsideup

    View Slide

  4. About me
    • Testcontainers co-maintainer

    • Staff Engineer at Pivotal’s Spring R&D, working on Project Reactor ⚛

    • Berlin Spring User Group co-organizer

    • Developer tools geek
    @bsideup

    View Slide

  5. Integration testing
    Why it matters?

    View Slide

  6. @bsideup

    View Slide

  7. @bsideup

    View Slide

  8. Integration testing
    Real-world, but isolated testing

    Spot the issues before the real
    environment

    Can be run during the
    development
    You have to start real databases

    Should be cross-platform

    Slower than Unit testing
    Pros Cons
    @bsideup

    View Slide

  9. Integration testing transformation
    @bsideup

    View Slide

  10. Mocking
    Integration testing transformation
    @bsideup

    View Slide

  11. Mocking
    Local DBs
    Integration testing transformation
    @bsideup

    View Slide

  12. Mocking
    Local DBs
    VMs

    (Vagrant)
    Integration testing transformation
    @bsideup

    View Slide

  13. Mocking
    Local DBs
    VMs

    (Vagrant)
    Docker
    Integration testing transformation
    @bsideup

    View Slide

  14. Mocking
    Local DBs
    VMs

    (Vagrant)
    Docker
    Fig

    (aka Docker Compose)
    Integration testing transformation
    @bsideup

    View Slide

  15. Mocking
    Local DBs
    VMs

    (Vagrant)
    Docker
    Fig

    (aka Docker Compose)
    Docker API
    Integration testing transformation
    @bsideup

    View Slide

  16. View Slide

  17. Abstraction layer

    View Slide

  18. CI friendly

    View Slide

  19. Cross-platform

    View Slide

  20. Docker Compose FTW!
    redis:

    image: redis

    ports:

    - "6379:6379"

    postgres:

    image: postgres

    ports:

    - "5432:5432"

    elasticsearch:

    image: elasticsearch:5.0.0

    ports:

    - "9200:9200"

    @bsideup

    View Slide

  21. But…

    View Slide

  22. Declarative YAML
    redis:

    image: redis

    ports:

    - "6379:6379"

    postgres:

    image: postgres

    ports:

    - "5432:5432"

    elasticsearch:

    image: elasticsearch:5.0.0

    ports:

    - "9200:9200"

    @bsideup

    View Slide

  23. ports randomization?
    redis:

    image: redis

    ports:

    - "6379:6379"

    postgres:

    image: postgres

    ports:

    - "5432:5432"

    elasticsearch:

    image: elasticsearch:5.0.0

    ports:

    - "9200:9200"

    @bsideup

    View Slide

  24. Container per test?
    redis:

    image: redis

    ports:

    - "6379:6379"

    postgres:

    image: postgres

    ports:

    - "5432:5432"

    elasticsearch:

    image: elasticsearch:5.0.0

    ports:

    - "9200:9200"

    @bsideup

    View Slide

  25. IDE integration?
    @bsideup

    View Slide

  26. Fighting with Docker environment

    View Slide

  27. There is no place like

    View Slide

  28. There is no place like
    … unless there is

    View Slide

  29. Can we improve that?

    View Slide

  30. View Slide

  31. Testcontainers
    • http://github.com/testcontainers/testcontainers-java
    • Wraps docker-java library

    • Docker environment discovery (Win, Mac, Linux)

    • Containers cleanup on JVM shutdown
    @bsideup

    View Slide

  32. As simple as
    PostgreSQLContainer postgresql = new PostgreSQLContainer()


    GenericContainer redis = new GenericContainer("redis:3")

    .withExposedPorts(6379)
    @bsideup

    View Slide

  33. Users

    View Slide

  34. @bsideup

    View Slide

  35. Demo

    View Slide

  36. 1.6.x
    Jan, 2018
    @bsideup

    View Slide

  37. 1.6.x
    • Kafka module
    Jan, 2018
    @bsideup
    try (KafkaContainer kafka = new KafkaContainer()) {
    kafka.start();
    testKafkaFunctionality(kafka.getBootstrapServers());
    }

    View Slide

  38. 1.6.x
    • Kafka module

    • “Ryuk”
    Jan, 2018
    @bsideup

    View Slide

  39. 1.7.x
    Apr, 2018
    @bsideup

    View Slide

  40. 1.7.x
    • Maven BOM
    Apr, 2018
    @bsideup



    org.testcontainers
    testcontainers-bom
    1.11.2
    bom
    import



    View Slide

  41. 1.7.x
    • Maven BOM

    • DockerCompose

    wait strategies
    Apr, 2018
    @bsideup
    new DockerComposeContainer(new File("docker-compose.yml"))
    .withExposedService(
    "redis_1",
    REDIS_PORT,
    Wait.forListeningPort()
    )
    .withExposedService(
    "db_1",
    3306,
    Wait.forLogMessage(".*ready for connections.*\\s", 1)
    );

    View Slide

  42. 1.7.x
    • Maven BOM

    • DockerCompose

    wait strategies

    • Daemon threads
    Apr, 2018
    @bsideup
    kiraThread.setDaemon(true);
    kiraThread.start();

    View Slide

  43. 1.7.x
    • Maven BOM

    • DockerCompose

    wait strategies

    • Daemon threads

    • MockServer module
    Apr, 2018
    @bsideup
    try (MockServerContainer mockServer = new MockServerContainer()) {
    mockServer.start();
    String expectedBody = "Hello Default World!";
    MockServerClient client = new MockServerClient(
    mockServer.getContainerIpAddress(),
    mockServer.getServerPort()
    );
    client.when(request("/hello")).respond(response(expectedBody));
    // ...
    }

    View Slide

  44. 1.8.x
    Jun, 2018
    @bsideup

    View Slide

  45. 1.8.x
    • OkHttp transport
    Jun, 2018
    @bsideup

    View Slide

  46. 1.8.x
    • OkHttp transport

    • Test framework

    agnostic
    Jun, 2018
    @bsideup
    public interface Startable extends AutoCloseable {
    void start();
    void stop();
    }
    public interface TestLifecycleAware {
    default void beforeTest(TestDescription description) {}
    default void afterTest(
    TestDescription description,
    Optional throwable
    ) {}
    }

    View Slide

  47. 1.8.x
    • OkHttp transport

    • Test framework

    agnostic

    • Docker cred. helpers
    Jun, 2018
    @bsideup
    {
    "auths": {
    },
    "HttpHeaders": {
    "User-Agent": "Docker-Client/18.03.0-ce (darwin)"
    },
    "credHelpers": {
    "registry.example.com": “helper"
    }
    }

    View Slide

  48. 1.8.x
    • OkHttp transport

    • Test framework

    agnostic

    • Docker cred. helpers

    • copyFileToContainer
    Jun, 2018
    @bsideup
    GenericContainer container = new GenericContainer("alpine:latest")
    // Look, Ma! No volumes mounting!
    .withCopyFileToContainer(
    MountableFile.forClasspathResource(“/mappable-resource/"),
    containerPath
    )

    View Slide

  49. 1.8.x
    • OkHttp transport

    • Test framework

    agnostic

    • Docker cred. helpers

    • copyFileToContainer

    • Pulsar module
    • Couchbase module
    • Cassandra module
    Jun, 2018
    @bsideup

    View Slide

  50. 1.9.x
    Sep, 2018
    @bsideup

    View Slide

  51. 1.9.x
    • OkHttp by default
    Sep, 2018
    @bsideup

    View Slide

  52. 1.9.x
    • OkHttp by default

    • Windows npipe support
    Sep, 2018
    @bsideup
    No longer needed

    View Slide

  53. 1.9.x
    • OkHttp by default

    • Windows npipe support

    • Registry auth on Windows
    Sep, 2018
    @bsideup

    View Slide

  54. 1.9.x
    • OkHttp by default

    • Windows npipe support

    • Registry auth on Windows

    • Fix local Docker Compose

    on Windows
    Sep, 2018
    @bsideup
    new DockerComposeContainer(new File("docker-compose.yml"))
    .withExposedService("redis_1", REDIS_PORT)
    .withExposedService("db_1", 3306)
    .withLocalCompose(true);

    View Slide

  55. 1.9.x
    • OkHttp by default

    • Windows npipe support

    • Registry auth on Windows

    • Fix local Docker Compose

    on Windows

    • Host ports exposing
    Sep, 2018
    @bsideup
    @BeforeClass
    public static void setUp() {
    localPort = server.getAddress().getPort();
    Testcontainers.exposeHostPorts(localPort);
    }
    @Rule
    public BrowserWebDriverContainer browser = new BrowserWebDriverContainer()
    .withCapabilities(new ChromeOptions());
    @Test
    public void testContainerRunningAgainstExposedHostPort() {
    RemoteWebDriver webDriver = browser.getWebDriver();
    webDriver.get( String.format("http://host.testcontainers.internal:%d/", localPort));
    final String pageSource = webDriver.getPageSource();
    assertTrue(pageSource.contains("Hello from the host!"));
    }

    View Slide

  56. 1.9.x
    • OkHttp by default

    • Windows npipe support

    • Registry auth on Windows

    • Fix local Docker Compose

    on Windows

    • Host ports exposing

    • Random ports in Couchbase
    Sep, 2018
    @bsideup

    View Slide

  57. 1.9.x
    • OkHttp by default

    • Windows npipe support

    • Registry auth on Windows

    • Fix local Docker Compose

    on Windows

    • Host ports exposing

    • Random ports in Couchbase
    Sep, 2018
    @bsideup

    View Slide

  58. 1.9.x
    • OkHttp by default

    • Windows npipe support

    • Registry auth on Windows

    • Fix local Docker Compose

    on Windows

    • Host ports exposing

    • Random ports in Couchbase

    • ClickHouse and Postgis modules
    Sep, 2018
    @bsideup

    View Slide

  59. 1.10.x
    Nov, 2018
    @bsideup

    View Slide

  60. 1.10.x
    • JUnit 5 support
    Nov, 2018
    @bsideup
    @Testcontainers
    class MyTestcontainersTests {
    // will be shared between test methods
    @Container
    static final MySQLContainer MYSQL_CONTAINER = new MySQLContainer();
    // will be started before and stopped after each test method
    @Container
    PostgreSQLContainer postgresqlContainer = new PostgreSQLContainer()
    .withDatabaseName("foo")
    .withUsername("foo")
    .withPassword(“secret");
    @Test
    void test() {
    assertTrue(MYSQL_CONTAINER.isRunning());
    assertTrue(postgresqlContainer.isRunning());
    }
    }

    View Slide

  61. 1.10.x
    • JUnit 5 support

    • New docs & more examples
    Nov, 2018
    @bsideup

    View Slide

  62. 1.10.x
    • JUnit 5 support

    • New docs & more examples

    • Env var to turn off Ryuk

    (for public CIs)
    Nov, 2018
    @bsideup

    View Slide

  63. 1.10.x
    • JUnit 5 support

    • New docs & more examples

    • Env var to turn off Ryuk

    (for public CIs)

    • shm + TmpFS settings
    Nov, 2018
    @bsideup

    View Slide

  64. 1.10.x
    • JUnit 5 support

    • New docs & more examples

    • Env var to turn off Ryuk

    (for public CIs)

    • shm + TmpFS settings

    • Auto dependency updates

    with Dependabot
    Nov, 2018
    @bsideup

    View Slide

  65. 1.10.x
    • JUnit 5 support

    • New docs & more examples

    • Env var to turn off Ryuk

    (for public CIs)

    • shm + TmpFS settings

    • Auto dependency updates

    with Dependabot
    Nov, 2018
    @bsideup

    View Slide

  66. 1.10.x
    • JUnit 5 support

    • New docs & more examples

    • Env var to turn off Ryuk

    (for public CIs)

    • shm + TmpFS settings

    • Auto dependency updates

    with Dependabot

    • Neo4j and Elasticsearch modules
    Nov, 2018
    @bsideup

    View Slide

  67. 1.11.x
    Mar, 2019
    @bsideup

    View Slide

  68. 1.11.x
    • Chaos Testing (toxiproxy)
    Mar, 2019
    @bsideup
    @Rule
    public GenericContainer redis = new GenericContainer("redis:5.0.4")
    .withExposedPorts(6379)
    .withNetwork(network);
    @Rule
    public ToxiproxyContainer toxiproxy = new ToxiproxyContainer()
    .withNetwork(network);
    @Test
    public void testLatencyViaProxy() throws IOException {
    ContainerProxy proxy = toxiproxy.getProxy(redis, 6379);
    Jedis jedis = new Jedis(
    proxy.getContainerIpAddress(),
    proxy.getProxyPort()
    );
    proxy.toxics()
    .latency("latency", ToxicDirection.DOWNSTREAM, 1_100)
    .setJitter(100);
    jedis.get("somekey");
    }

    View Slide

  69. 1.11.x
    • Chaos Testing (toxiproxy)

    • fsync=off for PostgreSQL
    Mar, 2019
    @bsideup

    View Slide

  70. 1.11.x
    • Chaos Testing (toxiproxy)

    • fsync=off for PostgreSQL

    • Drop Netty transport
    Mar, 2019
    @bsideup

    View Slide

  71. 1.11.x
    • Chaos Testing (toxiproxy)

    • fsync=off for PostgreSQL

    • Drop Netty transport
    Mar, 2019
    @bsideup

    View Slide

  72. 1.11.x
    • Chaos Testing (toxiproxy)

    • fsync=off for PostgreSQL

    • Drop Netty transport

    • Rework shading
    Mar, 2019
    @bsideup

    View Slide

  73. Roadmap

    View Slide

  74. new DSL

    View Slide

  75. What’s wrong with the current one?

    View Slide

  76. KafkaContainer kafka = new KafkaContainer()
    .withLogConsumer(new Slf4jLogConsumer(log))
    .withEmbeddedZookeeper()
    .withEnv("FOO", "BAR")
    .withStartupAttempts(5);
    Current DSL
    @bsideup

    View Slide

  77. KafkaContainer kafka = new KafkaContainer()
    .withLogConsumer(new Slf4jLogConsumer(log))
    .withEmbeddedZookeeper()
    .withEnv("FOO", "BAR")
    .withStartupAttempts(5);
    Current DSL
    GenericContainer
    @bsideup

    View Slide

  78. KafkaContainer kafka = new KafkaContainer()
    .withLogConsumer(new Slf4jLogConsumer(log))
    .withEmbeddedZookeeper()
    .withEnv("FOO", "BAR")
    .withStartupAttempts(5);
    Current DSL
    KafkaContainer
    @bsideup

    View Slide

  79. KafkaContainer kafka = new KafkaContainer()
    .withLogConsumer(new Slf4jLogConsumer(log))
    .withEmbeddedZookeeper()
    .withEnv("FOO", "BAR")
    .withStartupAttempts(5);
    Current DSL
    GenericContainer
    @bsideup

    View Slide

  80. KafkaContainer kafka = new KafkaContainer()
    .withLogConsumer(new Slf4jLogConsumer(log))
    .withEmbeddedZookeeper()
    .withEnv("FOO", "BAR")
    .withStartupAttempts(5);
    Current DSL
    GenericContainer
    @bsideup

    View Slide

  81. KafkaContainer kafka = new KafkaContainer()
    .withLogConsumer(new Slf4jLogConsumer(log))
    .withEmbeddedZookeeper()
    .withEnv("FOO", "BAR")
    .withStartupAttempts(5);
    Current DSL
    GenericContainer
    public SELF withStartupAttempts(int attempts) {
    this.startupAttempts = attempts;
    return self();
    }
    @bsideup

    View Slide

  82. public class KafkaContainer extends GenericContainer {}
    public class GenericContainer> /* */ {}
    @bsideup

    View Slide

  83. public class KafkaContainer extends GenericContainer {}
    public class GenericContainer> /* */ {}
    https://youtrack.jetbrains.com/issue/KT-17186
    https://stackoverflow.com/questions/39163749/how-to-use-a-java-self-bounded-class-in-scala
    @bsideup

    View Slide

  84. ❌ Hard to maintain
    @bsideup

    View Slide

  85. ❌ Hard to maintain
    ❌ Does not work with Kotlin/Scala
    @bsideup

    View Slide

  86. ❌ Hard to maintain
    ❌ Does not work with Kotlin/Scala
    ❌ Externally mutable objects
    @bsideup

    View Slide

  87. ❌ Hard to maintain
    ❌ Does not work with Kotlin/Scala
    ❌ Externally mutable objects
    ❌ “setX” isn’t supported, only “withX” (think collections)
    @bsideup

    View Slide

  88. ❌ Hard to maintain
    ❌ Does not work with Kotlin/Scala
    ❌ Externally mutable objects
    ❌ “setX” isn’t supported, only “withX” (think collections)
    ❌ No imperative “if-else” with the fluent style
    @bsideup

    View Slide

  89. KafkaContainer kafka = new KafkaContainer() {
    @Override
    protected void initialize() {
    withLogConsumer(new Slf4jLogConsumer(log));
    withEmbeddedZookeeper();
    withEnv("FOO", "BAR");
    withStartupAttempts(5);
    }
    };
    New “DSL”?
    @bsideup

    View Slide

  90. KafkaContainer kafka = new KafkaContainer() {
    @Override
    protected void initialize() {
    withLogConsumer(new Slf4jLogConsumer(log));
    withEmbeddedZookeeper();
    withEnv("FOO", "BAR");
    withStartupAttempts(5);
    }
    };
    New “DSL”?
    Or even…
    @bsideup

    View Slide

  91. KafkaContainer kafka = new KafkaContainer() {{
    withLogConsumer(new Slf4jLogConsumer(log));
    withEmbeddedZookeeper();
    withEnv("FOO", "BAR");
    withStartupAttempts(5);
    }}
    New “DSL”?
    @bsideup

    View Slide

  92. KafkaContainer kafka = new KafkaContainer() {{
    withLogConsumer(new Slf4jLogConsumer(log));
    withEmbeddedZookeeper();
    withEnv("FOO", "BAR");
    withStartupAttempts(5);
    }}
    New “DSL”?
    public void withStartupAttempts(int attempts) {
    this.startupAttempts = attempts;
    }
    @bsideup

    View Slide

  93. KafkaContainer kafka = new KafkaContainer() {{
    withLogConsumer(new Slf4jLogConsumer(log));
    withEmbeddedZookeeper();
    withEnv("FOO", "BAR");
    withStartupAttempts(5);
    }}
    New “DSL”?
    public void withStartupAttempts(int attempts) {
    this.startupAttempts = attempts;
    }
    @bsideup

    View Slide

  94. ✓ Super easy to maintain
    @bsideup

    View Slide

  95. ✓ Super easy to maintain
    ✓ Works with any JVM language
    @bsideup

    View Slide

  96. ✓ Super easy to maintain
    ✓ Works with any JVM language
    ✓ “Controllable mutability” - no modifications outside of
    “initialize”
    @bsideup

    View Slide

  97. ✓ Super easy to maintain
    ✓ Works with any JVM language
    ✓ “Controllable mutability” - no modifications outside of
    “initialize”
    ✓ Void-retuning “setX” can easily be used
    @bsideup

    View Slide

  98. ✓ Super easy to maintain
    ✓ Works with any JVM language
    ✓ “Controllable mutability” - no modifications outside of
    “initialize”
    ✓ Void-retuning “setX” can easily be used
    ✓ Can use “if-else”
    @bsideup

    View Slide

  99. GraalVM focus

    View Slide

  100. @bsideup
    https://medium.com/graalvm/using-testcontainers-from-a-node-js-
    application-3aa2273bf3bb

    View Slide

  101. @bsideup
    // JavaScript
    var GenericContainer = Java.type(’org.testcontainers.containers.GenericContainer’);
    var container = new GenericContainer("nginx");
    container.setExposedPorts([80]);
    container.start();
    console.log(container.getContainerIpAddress() + ‘:’ + container.getMappedPort(80));
    https://medium.com/graalvm/using-testcontainers-from-a-node-js-
    application-3aa2273bf3bb

    View Slide

  102. @bsideup
    // JavaScript
    var GenericContainer = Java.type(’org.testcontainers.containers.GenericContainer’);
    var container = new GenericContainer("nginx");
    container.setExposedPorts([80]);
    container.start();
    console.log(container.getContainerIpAddress() + ‘:’ + container.getMappedPort(80));
    https://medium.com/graalvm/using-testcontainers-from-a-node-js-
    application-3aa2273bf3bb
    // Python
    import java
    generic = java.type('org.testcontainers.containers.GenericContainer')
    container = generic('nginx')
    container.setExposedPorts([80])
    container.start();
    print('%s:%s' % (container.getContainerIpAddress(), container.getMappedPort(80)));

    View Slide

  103. “container core”

    View Slide

  104. Testcontainers 1.x architecture
    Core

    (Docker, GenericContainer, 

    JUnit 4 int, test lifecycle)
    @bsideup

    View Slide

  105. Testcontainers 1.x architecture
    Core

    (Docker, GenericContainer, 

    JUnit 4 int, test lifecycle)
    JUnit Jupiter int.
    @bsideup

    View Slide

  106. Testcontainers 1.x architecture
    Core

    (Docker, GenericContainer, 

    JUnit 4 int, test lifecycle)
    JUnit Jupiter int.
    Modules like MySQL/Kafka/…
    @bsideup

    View Slide

  107. Testcontainers 2.x architecture
    Container-core

    (GenericContainer)
    @bsideup

    View Slide

  108. Testcontainers 2.x architecture
    Container-core

    (GenericContainer)
    Test Frameworks
    JUnit 4
    JUnit Jupiter
    Scala test?

    @bsideup

    View Slide

  109. Testcontainers 2.x architecture
    Container-core

    (GenericContainer)
    Test Frameworks
    JUnit 4
    JUnit Jupiter
    Scala test?

    Executing engines
    Container-core-docker

    (env discovery, networks, …)
    Container-core-k8s?

    @bsideup

    View Slide

  110. Modules like MySQL/Kafka/…
    Testcontainers 2.x architecture
    Container-core

    (GenericContainer)
    Test Frameworks
    JUnit 4
    JUnit Jupiter
    Scala test?

    Executing engines
    Container-core-docker

    (env discovery, networks, …)
    Container-core-k8s?

    @bsideup

    View Slide

  111. View Slide

  112. Usability
    Flexibility
    Speed
    Features
    @bsideup

    View Slide

  113. Usability
    Flexibility
    Speed
    Features
    @bsideup

    View Slide

  114. Usability
    Flexibility
    Speed
    Features
    @bsideup

    View Slide

  115. Please welcome…

    View Slide

  116. Reusable
    containers

    View Slide

  117. Reusable
    containers

    View Slide

  118. Demo

    View Slide

  119. @bsideup

    View Slide

  120. ✓ Ultra-fast ITDD (Integration Test Driven Development)
    @bsideup

    View Slide

  121. ✓ Ultra-fast ITDD (Integration Test Driven Development)
    ✓ Minimal effort for the users
    @bsideup

    View Slide

  122. ✓ Ultra-fast ITDD (Integration Test Driven Development)
    ✓ Minimal effort for the users
    ✓ Works for most of the containers
    @bsideup

    View Slide

  123. ✓ Ultra-fast ITDD (Integration Test Driven Development)
    ✓ Minimal effort for the users
    ✓ Works for most of the containers
    ✓ Eventually cleanups stale containers

    (unlike Docker Compose)
    @bsideup

    View Slide

  124. ✓ Ultra-fast ITDD (Integration Test Driven Development)
    ✓ Minimal effort for the users
    ✓ Works for most of the containers
    ✓ Eventually cleanups stale containers

    (unlike Docker Compose)
    ✓ Available later this year. Pre-order now for $0
    @bsideup

    View Slide

  125. Takeaways
    • https://testcontainers.org
    • Works on Linux, Mac and Windows
    • …including CIs like Jenkins, Travis, CircleCI, BitBucket, AzurePipelines, …

    • Provides a great balance between 

    flexibility, usability, speed and features

    @bsideup

    View Slide

  126. @bsideup
    bsideup

    View Slide