diff --git a/.github/workflows/README.md b/.github/workflows/README.md new file mode 100644 index 0000000..b1402b0 --- /dev/null +++ b/.github/workflows/README.md @@ -0,0 +1,186 @@ +# GitHub Actions Workflow Setup + +This directory contains GitHub Actions workflows for automated Docker image building and publishing. + +## Workflow: `docker-build-push.yml` + +Automatically builds all three Dockerfile variants and pushes them to GitHub Container Registry (GHCR). + +### Prerequisites + +1. **Enable GitHub Actions**: Should be enabled by default for most repositories +2. **Enable GitHub Container Registry**: Automatically available for public repositories +3. **Set Repository Permissions** (for private repos): + - Go to Settings → Actions → General + - Under "Workflow permissions", ensure "Read and write permissions" is selected + +### First-Time Setup + +No additional configuration needed! The workflow uses `GITHUB_TOKEN` which is automatically provided by GitHub Actions. + +#### For Private Repositories + +If your repository is private, you may need to: + +1. Go to your repository Settings → Packages +2. Under "Package settings", ensure the package visibility is set appropriately +3. Add collaborators if needed + +### How It Works + +The workflow triggers on: + +- **Push to `main` or `develop` branches**: Builds and pushes all three variants +- **Creating a tag** (e.g., `v1.0.0`): Builds and pushes with version tags +- **Pull requests to `main`**: Builds images but doesn't push (validation only) +- **Manual trigger**: Can run manually from Actions tab + +### Image Naming Convention + +Images are pushed to: `ghcr.io//:` + +For example: +- `ghcr.io/sumedhsankhe/shiny-docker-optimization:three-stage-latest` +- `ghcr.io/sumedhsankhe/shiny-docker-optimization:v1.0.0-three-stage` +- `ghcr.io/sumedhsankhe/shiny-docker-optimization:main-two-stage` + +### Tags Created + +Each build creates multiple tags: + +| Tag Pattern | Example | Description | +|-------------|---------|-------------| +| `{variant}-latest` | `three-stage-latest` | Latest build from main branch | +| `{branch}-{variant}` | `main-three-stage` | Latest build from specific branch | +| `{variant}-sha-{hash}` | `three-stage-sha-abc1234` | Specific commit | +| `v{version}-{variant}` | `v1.0.0-three-stage` | Semantic version (on tag) | + +### Matrix Strategy + +The workflow builds all three variants **independently and in parallel**: + +1. **single-stage** (`Dockerfile.single-stage`) +2. **two-stage** (`Dockerfile.multistage`) +3. **three-stage** (`Dockerfile.three-stage`) + +**Important**: The builds run independently with `fail-fast: false`, meaning: +- If one variant fails to build, the other variants continue building +- Each successful build pushes to GHCR independently +- The image size comparison only runs if ALL THREE builds succeed +- This provides better visibility into which specific variants have issues + +### Build Caching + +GitHub Actions caching is enabled to speed up builds: + +- **Type**: GitHub Actions cache (GHA) +- **Mode**: Max (caches all layers) +- **Result**: Subsequent builds only rebuild changed layers + +Expected build times: +- First build (cold cache): 12-15 minutes +- Code change only: ~30 seconds +- Dependency change: 8-10 minutes + +### Pulling Images + +After a successful workflow run, pull images with: + +```bash +# Login (required for private repos only) +echo $GITHUB_TOKEN | docker login ghcr.io -u USERNAME --password-stdin + +# Pull latest three-stage image (recommended) +docker pull ghcr.io//:three-stage-latest + +# Pull specific version +docker pull ghcr.io//:v1.0.0-three-stage + +# Run the image +docker run -p 3838:3838 ghcr.io//:three-stage-latest +``` + +Replace `/` with your GitHub username and repository name. + +### Creating a Release + +To trigger a versioned build: + +```bash +# Tag your commit +git tag -a v1.0.0 -m "Release version 1.0.0" +git push origin v1.0.0 +``` + +This will create images tagged as: +- `v1.0.0-single-stage` +- `v1.0.0-two-stage` +- `v1.0.0-three-stage` +- `1.0-single-stage` (major.minor) +- `1.0-two-stage` +- `1.0-three-stage` + +### Monitoring Workflow Runs + +1. Go to the **Actions** tab in your GitHub repository +2. Click on "Build and Push Docker Images" workflow +3. View individual workflow runs: + - Build logs for each variant + - Image size comparison + - Created tags + - Pull commands + +### Troubleshooting + +#### Workflow fails with "permission denied" + +**Solution**: Check repository settings: +1. Settings → Actions → General +2. Workflow permissions → "Read and write permissions" +3. Save changes + +#### Cannot pull image + +**For private repos**: +```bash +# Create a Personal Access Token (PAT) with read:packages scope +# Then login: +echo $PAT | docker login ghcr.io -u USERNAME --password-stdin +``` + +**For public repos**: No authentication needed, images are publicly accessible + +#### Build is slow + +- First build will always be slow (12-15 min) as cache is built +- Subsequent builds should be much faster (~30s for code changes) +- Check Actions → Cache to see cached artifacts + +### Customization + +To modify the workflow: + +1. Edit `.github/workflows/docker-build-push.yml` +2. Common modifications: + - Add/remove trigger branches + - Change image registry (e.g., Docker Hub instead of GHCR) + - Modify tagging strategy + - Add additional build platforms + - Add security scanning + +### Best Practices + +1. **Use three-stage variant** for production (best caching, smallest runtime image) +2. **Tag releases** with semantic versioning (v1.0.0, v1.1.0, etc.) +3. **Monitor build times** to ensure caching is working effectively +4. **Review image sizes** in the workflow summary after each build +5. **Pull specific versions** in production rather than `-latest` tags + +### Security + +- Workflow uses `GITHUB_TOKEN` which is scoped to the repository +- Images can be: + - Public (anyone can pull) + - Private (requires authentication) +- Set package visibility in Settings → Packages +- Consider adding vulnerability scanning (Trivy, Snyk) for production use diff --git a/.github/workflows/docker-build-with-cache.yml b/.github/workflows/docker-build-with-cache.yml new file mode 100644 index 0000000..c328602 --- /dev/null +++ b/.github/workflows/docker-build-with-cache.yml @@ -0,0 +1,171 @@ +name: Build with Hash-Based Caching + +# Demonstrates Part 2 concepts: +# 1. Hash-based base image caching +# 2. Cache busting for external packages +# 3. Tests as build gate + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + inputs: + cache-bust: + description: 'Force rebuild of package layer' + required: false + type: string + default: '' + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-with-tests: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # ======================================================================== + # HASH-BASED BASE IMAGE CACHING + # ======================================================================== + - name: Compute renv.lock hash + id: renv-hash + run: | + RENV_HASH=$(sha256sum renv.lock | cut -c1-12) + echo "hash=${RENV_HASH}" >> $GITHUB_OUTPUT + echo "::notice::renv.lock hash: ${RENV_HASH}" + + - name: Check if base image exists + id: check-base + run: | + # Lowercase for Docker registry + IMAGE_LOWER=$(echo "${{ env.IMAGE_NAME }}" | tr '[:upper:]' '[:lower:]') + BASE_TAG="${{ env.REGISTRY }}/${IMAGE_LOWER}-base:${{ steps.renv-hash.outputs.hash }}" + + echo "base_tag=${BASE_TAG}" >> $GITHUB_OUTPUT + + if docker pull "${BASE_TAG}" 2>/dev/null; then + echo "::notice::Base image found in registry (using cache)" + echo "needs_build=false" >> $GITHUB_OUTPUT + else + echo "::notice::Base image not found (will build)" + echo "needs_build=true" >> $GITHUB_OUTPUT + fi + + - name: Build and push base image (if needed) + if: steps.check-base.outputs.needs_build == 'true' + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile.base + push: true + tags: ${{ steps.check-base.outputs.base_tag }} + build-args: | + RENV_HASH=${{ steps.renv-hash.outputs.hash }} + cache-from: type=gha,scope=base + cache-to: type=gha,mode=max,scope=base + + # ======================================================================== + # BUILD APP WITH TESTS (uses base image) + # ======================================================================== + - name: Determine cache bust value + id: cache-bust + run: | + if [ -n "${{ inputs.cache-bust }}" ]; then + echo "value=${{ inputs.cache-bust }}" >> $GITHUB_OUTPUT + else + # Default: use run ID for unique builds + echo "value=${{ github.run_id }}" >> $GITHUB_OUTPUT + fi + + - name: Build app with tests + id: build-app + continue-on-error: true + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile.with-tests + target: runtime + push: ${{ github.event_name != 'pull_request' }} + tags: | + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:with-tests-latest + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:with-tests-${{ github.sha }} + build-args: | + BASE_IMAGE=${{ steps.check-base.outputs.base_tag }} + CACHE_BUST=${{ steps.cache-bust.outputs.value }} + cache-from: type=gha,scope=app + cache-to: type=gha,mode=max,scope=app + + # ======================================================================== + # EXTRACT TEST RESULTS (even if build failed) + # ======================================================================== + - name: Extract test results from builder stage + if: always() + run: | + IMAGE_LOWER=$(echo "${{ env.IMAGE_NAME }}" | tr '[:upper:]' '[:lower:]') + + # Build just the builder stage to extract test results + docker build \ + --target builder \ + --build-arg BASE_IMAGE=${{ steps.check-base.outputs.base_tag }} \ + --build-arg CACHE_BUST=${{ steps.cache-bust.outputs.value }} \ + -f Dockerfile.with-tests \ + -t temp-builder . || true + + # Extract test results + docker create --name temp temp-builder 2>/dev/null || true + docker cp temp:/tmp/test-results.xml ./test-results.xml 2>/dev/null || true + docker rm temp 2>/dev/null || true + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: test-results.xml + if-no-files-found: ignore + + # ======================================================================== + # FAIL IF TESTS FAILED + # ======================================================================== + - name: Check build status + if: steps.build-app.outcome != 'success' + run: | + echo "::error::Build failed - tests may have failed" + exit 1 + + # ======================================================================== + # BUILD SUMMARY + # ======================================================================== + - name: Generate build summary + if: success() + run: | + echo "## Build Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Component | Value |" >> $GITHUB_STEP_SUMMARY + echo "|-----------|-------|" >> $GITHUB_STEP_SUMMARY + echo "| renv.lock hash | \`${{ steps.renv-hash.outputs.hash }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| Base image rebuilt | ${{ steps.check-base.outputs.needs_build }} |" >> $GITHUB_STEP_SUMMARY + echo "| Cache bust value | \`${{ steps.cache-bust.outputs.value }}\` |" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Images" >> $GITHUB_STEP_SUMMARY + echo "- Base: \`${{ steps.check-base.outputs.base_tag }}\`" >> $GITHUB_STEP_SUMMARY + echo "- App: \`${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:with-tests-latest\`" >> $GITHUB_STEP_SUMMARY diff --git a/Dockerfile.base b/Dockerfile.base new file mode 100644 index 0000000..18b5b6f --- /dev/null +++ b/Dockerfile.base @@ -0,0 +1,70 @@ +# ============================================================================ +# BASE IMAGE: Hash-Based Caching Layer +# ============================================================================ +# This image contains all renv.lock dependencies and is tagged with the +# SHA256 hash of renv.lock. It only rebuilds when dependencies change. +# +# Tag format: shiny-app-base:- +# Example: shiny-app-base:main-a3b2c1d4e5f6 +# +# Usage in CI: +# RENV_HASH=$(sha256sum renv.lock | cut -c1-12) +# BASE_TAG="shiny-app-base:${BRANCH}-${RENV_HASH}" +# if ! docker pull registry/${BASE_TAG}; then +# docker build -f Dockerfile.base -t registry/${BASE_TAG} . +# fi +# ============================================================================ + +FROM rocker/r2u:24.04 + +LABEL maintainer="Your Name" \ + description="Base image with renv.lock dependencies" + +# Configure renv cache path (must match app Dockerfile) +ENV RENV_PATHS_CACHE="/app/renv/.cache" + +WORKDIR /app + +# Copy ONLY renv configuration files +# These are the cache key - changes here trigger rebuild +COPY renv.lock renv.lock +COPY .Rprofile .Rprofile +COPY renv/activate.R renv/activate.R +COPY renv/settings.json renv/settings.json + +# Install system dependencies for R packages +RUN apt-get update && apt-get install -y --no-install-recommends \ + libcurl4-openssl-dev \ + libssl-dev \ + libxml2-dev \ + libfontconfig1-dev \ + libharfbuzz-dev \ + libfribidi-dev \ + libfreetype6-dev \ + libpng-dev \ + libtiff5-dev \ + libjpeg-dev \ + && rm -rf /var/lib/apt/lists/* + +# Create renv cache directory +RUN mkdir -p ${RENV_PATHS_CACHE} + +# Install renv and restore all packages from lock file +RUN R -e "install.packages('renv', repos = 'https://cloud.r-project.org')" +RUN R -e "options(renv.consent = TRUE); renv::restore()" + +# Verify installation +RUN R -e "cat('Base image ready\n'); renv::status()" + +# Label with renv.lock hash for traceability +# Set via: --build-arg RENV_HASH=$(sha256sum renv.lock | cut -c1-12) +ARG RENV_HASH +LABEL renv.lock.hash="${RENV_HASH}" + +# ============================================================================ +# Build Command: +# RENV_HASH=$(sha256sum renv.lock | cut -c1-12) +# docker build -f Dockerfile.base \ +# --build-arg RENV_HASH=${RENV_HASH} \ +# -t shiny-app-base:main-${RENV_HASH} . +# ============================================================================ diff --git a/Dockerfile.with-tests b/Dockerfile.with-tests new file mode 100644 index 0000000..03d8e5e --- /dev/null +++ b/Dockerfile.with-tests @@ -0,0 +1,124 @@ +# ============================================================================ +# MULTI-STAGE BUILD WITH TEST GATE +# ============================================================================ +# Tests run as part of the build - if tests fail, the image doesn't get built. +# This ensures only tested code reaches production. +# +# Stages: +# 1. builder - Runs tests, installs packages +# 2. runtime - Production image (excludes tests/) +# +# Key features: +# - Tests as build gate (no image if tests fail) +# - JUnit XML output for CI integration +# - Cache busting for external packages +# - Clean runtime image (no test files) +# ============================================================================ + +# Base image passed as build arg (from Dockerfile.base with hash tag) +ARG BASE_IMAGE + +# ============================================================================ +# STAGE 1: Builder - Run Tests +# ============================================================================ +FROM ${BASE_IMAGE} AS builder + +# Cache bust arg - change this to force reinstall of external packages +# Usage: --build-arg CACHE_BUST=$(date +%s) +ARG CACHE_BUST=0 + +WORKDIR /app + +# Copy entire application including tests +COPY . /app/ + +# Optional: Install additional packages not in renv.lock +# This layer rebuilds when CACHE_BUST changes +RUN echo "Cache bust: ${CACHE_BUST}" && \ + echo "External packages would be installed here" +# Example: R -e "renv::install('org/package@branch')" + +# ============================================================================ +# RUN TESTS - Build FAILS if tests fail +# ============================================================================ +# This is the gate - no passing tests, no image +RUN R -e "testthat::test_dir('tests/testthat', \ + reporter = testthat::JunitReporter\$new(file = '/tmp/test-results.xml'), \ + stop_on_failure = TRUE)" + +# Verify test results were generated +RUN test -f /tmp/test-results.xml || \ + (echo "ERROR: Test results not generated" && exit 1) + +# ============================================================================ +# STAGE 2: Production Runtime +# ============================================================================ +# This stage only builds if tests passed in builder stage +FROM rocker/r2u:24.04 AS runtime + +LABEL description="Production runtime (tests excluded)" + +ENV RENV_PATHS_CACHE="/app/renv/.cache" + +# Install ONLY runtime system dependencies (no -dev packages) +RUN apt-get update && apt-get install -y --no-install-recommends \ + libcurl4 \ + libssl3 \ + libxml2 \ + libfontconfig1 \ + libharfbuzz0b \ + libfribidi0 \ + libfreetype6 \ + libpng16-16t64 \ + libtiff6 \ + libjpeg-turbo8 \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Copy ONLY production artifacts from builder (excludes tests/) +COPY --from=builder /app/renv/ /app/renv/ +COPY --from=builder /app/.Rprofile /app/.Rprofile +COPY --from=builder /app/renv.lock /app/renv.lock +COPY --from=builder /app/app.R /app/app.R + +# NOTE: Explicitly NOT copying: +# - tests/ +# - /tmp/test-results.xml +# These remain in builder stage only + +# Create non-root user for security +RUN groupadd --system app && \ + useradd --system --gid app app && \ + chown -R app:app /app + +USER app + +EXPOSE 3838 + +CMD ["R", "--vanilla", "-e", ".libPaths('/app/renv/library/linux-ubuntu-noble/R-4.5/x86_64-pc-linux-gnu'); shiny::runApp('/app', host = '0.0.0.0', port = 3838)"] + +# ============================================================================ +# Build Commands: +# ============================================================================ +# +# 1. First, build or pull base image: +# RENV_HASH=$(sha256sum renv.lock | cut -c1-12) +# docker build -f Dockerfile.base -t shiny-app-base:${RENV_HASH} . +# +# 2. Build app with tests: +# docker build -f Dockerfile.with-tests \ +# --build-arg BASE_IMAGE=shiny-app-base:${RENV_HASH} \ +# --build-arg CACHE_BUST=$(date +%s) \ +# -t shiny-app:tested . +# +# 3. If tests fail, build aborts - no image created +# +# 4. Extract test results from failed build: +# docker build --target builder \ +# --build-arg BASE_IMAGE=shiny-app-base:${RENV_HASH} \ +# -t temp-builder . || true +# docker create --name temp temp-builder +# docker cp temp:/tmp/test-results.xml ./test-results.xml +# docker rm temp +# ============================================================================ diff --git a/README.md b/README.md index 99323b8..d846f33 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,9 @@ A practical demonstration of Docker optimization techniques for R Shiny applications, showing how multistage builds with rocker/r2u can reduce image size by 25% and improve build times by 80-94% through better layer caching and binary package installation. -> **Blog Post:** [Read the full story](./blog-post.md) about optimizing Docker builds for a customer-facing R Shiny SaaS application running on Kubernetes. +> **Blog Post Series:** +> - **[Part 1: Multistage Builds & Layer Caching](./blog-post.md)** - The foundation: reducing build times by 80-94% and image sizes by 25% +> - **[Part 2: Intelligent Caching Strategies](./blog-post-part-2.md)** - Advanced patterns: hash-based base images, cache busting, and tests as build gates ## The Problem @@ -205,15 +207,29 @@ COPY app.R . ``` shiny-docker-optimization/ -├── app.R # Example Shiny application -├── Dockerfile.single-stage # Before: Single-stage build -├── Dockerfile.multistage # After: Optimized multistage build -├── renv.lock # R package dependencies -├── .Rprofile # renv activation +├── app.R # Example Shiny application +├── blog-post.md # Part 1: Multistage builds +├── blog-post-part-2.md # Part 2: Intelligent caching +├── Dockerfile.single-stage # Before: Single-stage build +├── Dockerfile.multistage # After: Optimized two-stage build +├── Dockerfile.three-stage # Advanced: Three-stage build +├── Dockerfile.base # Part 2: Base image with hash-based caching +├── Dockerfile.with-tests # Part 2: Multistage with test gate +├── docker-compose.yml # Multi-profile compose file +├── test-build-times.sh # Local build velocity testing script +├── renv.lock # R package dependencies +├── .Rprofile # renv activation ├── renv/ -│ ├── activate.R # renv bootstrap script -│ └── settings.json # renv configuration -└── README.md # This file +│ ├── activate.R # renv bootstrap script +│ └── settings.json # renv configuration +├── tests/ +│ └── testthat/ +│ └── test-app.R # Part 2: Test suite +├── .github/ +│ └── workflows/ +│ ├── docker-build-push.yml # Part 1: CI/CD workflow +│ └── docker-build-with-cache.yml # Part 2: Intelligent caching workflow +└── README.md # This file ``` ## Customization Guide diff --git a/blog-post-part-2.md b/blog-post-part-2.md new file mode 100644 index 0000000..a9b5708 --- /dev/null +++ b/blog-post-part-2.md @@ -0,0 +1,262 @@ +--- +title: "Docker Optimization Part 2: Intelligent Caching for R Shiny Applications" +date: 2025-12-23 +author: Sumedh R. Sankhe +tags: [Docker, R, Shiny, DevOps, CI/CD, GitHub Actions, Testing] +description: "Advanced Docker caching strategies: hash-based base images, tests as build gates, and cache busting for external packages" +--- + +# Docker Optimization Part 2: Intelligent Caching for R Shiny Applications + +In [Part 1](docker-optimization.html), we covered the basics of multistage builds and layer caching for R Shiny applications. If you haven't read it, the TL;DR is: separate your slow-changing dependencies from fast-changing application code, and Docker's layer cache will reward you with faster builds. + +But here's what I didn't tell you: that approach has a blind spot. A big one. + +## The Problem Nobody Talks About + +Picture this: your `renv.lock` hasn't changed in two weeks. Your Dockerfile is identical. You push a one-line bug fix. Docker sees nothing changed in the dependency layers, serves everything from cache, and your build finishes in 3 minutes. Beautiful. + +Except... your teammate just pushed a critical fix to an internal R package hosted on GitHub. Your build used the cached version. The fix isn't in your image. You deploy. Production breaks. + +This is the "phantom dependency" problem. Docker's layer cache is content-addressed—it only knows about files *in your repository*. It has no idea that `renv::install("org/package")` now points to different code than it did yesterday. + +We needed to solve two distinct caching problems: + +1. **Lock file changes**: When `renv.lock` updates, rebuild the base image +2. **External package changes**: When upstream GitHub packages change (but lock file doesn't), invalidate just that layer + +And while we're at it, why not make tests a build gate? If tests fail, the image shouldn't exist. + +## Solution 1: Hash-Based Base Images + +The insight is simple: treat your lock file as a cache key. Same lock file = same dependencies = reuse the image. Different hash = rebuild. + +Here's the mechanism: + +```bash +# Compute a 12-character hash of your lock file +LOCK_HASH=$(sha256sum renv.lock | cut -c1-12) + +# Tag your base image with the hash +BASE_TAG="my-app-base:${VERSION}-${LOCK_HASH}" +# Example: my-app-base:1.4-dev-a3b2c1d4e5f6 +``` + +Now your CI workflow becomes: + +```yaml +- name: Check if base image exists + run: | + LOCK_HASH=$(sha256sum renv.lock | cut -c1-12) + BASE_TAG="my-app-base:${BRANCH}-${LOCK_HASH}" + + if docker pull "registry.example.com/${BASE_TAG}" 2>/dev/null; then + echo "Base image found - using cache" + echo "needs_build=false" >> $GITHUB_OUTPUT + else + echo "Base image not found - will build" + echo "needs_build=true" >> $GITHUB_OUTPUT + fi + +- name: Build base image (if needed) + if: steps.check.outputs.needs_build == 'true' + run: | + docker build -f Dockerfile.base \ + --build-arg LOCK_HASH=${LOCK_HASH} \ + -t registry.example.com/${BASE_TAG} . + docker push registry.example.com/${BASE_TAG} +``` + +The base Dockerfile installs everything from your lock file: + +```dockerfile +# Dockerfile.base - Stable dependency layer +FROM rocker/r2u:24.04 + +COPY renv.lock /app/renv.lock +COPY .Rprofile /app/.Rprofile +COPY renv/activate.R /app/renv/activate.R + +WORKDIR /app + +# Restore all packages from lock file +RUN R -e "renv::restore()" + +# Label with hash for traceability +ARG LOCK_HASH +LABEL renv.lock.hash="${LOCK_HASH}" +``` + +**Why this works**: The first PR that updates `renv.lock` pays the ~15 minute base image build cost. Every subsequent PR targeting that branch (with the same lock file) gets instant cache hits. When someone updates dependencies again, only then does the base image rebuild. + +In practice, we saw base image rebuilds drop from "every PR" to "2-3 times per release cycle." + +## Solution 2: Cache Busting for External Packages + +But what about packages not in your lock file? Maybe you have internal GitHub packages that follow branch conventions (e.g., `org/analytics-core@1.4-dev`). These update frequently, but your lock file doesn't track their commits. + +Docker needs a signal that something changed. We give it one: + +```dockerfile +# Main Dockerfile +ARG BASE_IMAGE +ARG CACHE_BUST=0 + +FROM ${BASE_IMAGE} AS builder + +# This layer rebuilds when CACHE_BUST changes +RUN echo "Cache bust: ${CACHE_BUST}" && \ + R -e "renv::install('org/analytics-core@${BRANCH}')" +``` + +The `echo` statement is the key. Docker evaluates build args, sees that `CACHE_BUST` changed, and invalidates this layer and everything after it. + +In your CI: + +```yaml +build-args: | + BASE_IMAGE=registry.example.com/${BASE_TAG} + CACHE_BUST=${{ github.run_id }} +``` + +Using `github.run_id` means every build gets fresh external packages. But you can also make it smarter: + +```yaml +# Only bust cache when triggered by upstream repo webhook +cache-bust: ${{ inputs.cache-bust || 'stable' }} +``` + +This way, normal PRs use cached packages (fast), but when an upstream repo dispatches a workflow trigger, you pass a new cache-bust value and force a fresh install. + +## Solution 3: Tests as Build Gates + +Here's a pattern I wish I'd adopted earlier: make your Docker build fail if tests fail. Not "build the image, then run tests in a separate job." The image literally doesn't get created unless tests pass. + +```dockerfile +# Dockerfile - Multi-stage with test gate +ARG BASE_IMAGE + +# Stage 1: Build and Test +FROM ${BASE_IMAGE} AS builder + +COPY . /app/ +WORKDIR /app + +# Install branch-specific packages +RUN R -e "renv::install('org/package@${BRANCH}')" + +# Run tests - build FAILS if tests fail +# JUnit reporter writes XML for CI systems to parse +RUN R -e "testthat::test_dir('tests/testthat', \ + reporter = testthat::JunitReporter\$new(file = '/tmp/test-results.xml'), \ + stop_on_failure = TRUE)" + +# Verify results were generated +RUN test -f /tmp/test-results.xml || exit 1 + + +# Stage 2: Production Runtime +FROM rocker/r2u:24.04 AS runtime + +WORKDIR /app + +# Copy ONLY production artifacts (no tests/) +COPY --from=builder /app/renv/ /app/renv/ +COPY --from=builder /app/R/ /app/R/ +COPY --from=builder /app/global.R /app/ +COPY --from=builder /app/server.R /app/ +COPY --from=builder /app/ui.R /app/ + +# Note: tests/ directory stays in builder stage - not copied to runtime +``` + +The key insight: `stop_on_failure = TRUE` makes `testthat::test_dir()` return a non-zero exit code when tests fail, which causes the `RUN` instruction to fail, which stops the entire build. No tests passing = no image. + +**Extracting test results**: The tricky part is getting JUnit XML out of a failed build for CI reporting. You need to build just the builder stage, then copy the results out: + +```yaml +- name: Extract test results + if: always() # Run even if build failed + run: | + # Build only the builder stage (continues even if tests failed) + docker build --target builder -t temp-builder . || true + + # Create container and copy results out + docker create --name temp temp-builder + docker cp temp:/tmp/test-results.xml ./test-results.xml || true + docker rm temp + +- name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: test-results.xml +``` + +## Putting It All Together + +Here's the flow: + +``` +PR Opened + │ + ▼ +┌─────────────────────────────────────┐ +│ Compute renv.lock hash │ +│ Check if base image exists in ACR │ +└─────────────────────────────────────┘ + │ + ├── Cache HIT ──────────────────────┐ + │ │ + ▼ ▼ +┌──────────────────┐ ┌─────────────────────────┐ +│ Build base image │ │ Skip base build │ +│ (~15 min) │ │ (0 sec) │ +└──────────────────┘ └─────────────────────────┘ + │ │ + └───────────────┬───────────────────┘ + ▼ + ┌───────────────────────┐ + │ Build app image │ + │ • Install GH packages │ + │ • Run tests │ + │ • Build runtime │ + └───────────────────────┘ + │ + ┌───────────┴───────────┐ + │ │ + ▼ ▼ + Tests PASS Tests FAIL + │ │ + ▼ ▼ + Push image Build aborts + to registry No image created +``` + +## Results + +| Scenario | Before | After | +|----------|--------|-------| +| PR with no dependency changes | 18-22 min | 4-6 min | +| PR with renv.lock changes | 18-22 min | 18-22 min (expected) | +| Base image cache hit rate | 0% | ~85% | +| Test failures caught pre-push | 0% | 100% | + +The biggest win isn't even the time savings—it's confidence. When an image exists in the registry, you *know* it passed tests. No more "the tests ran in a separate job that we forgot to check." + +## Things That Broke Along the Way + +**1. Registry authentication timing**: We tried checking if the base image exists *before* logging into the container registry. Obvious in hindsight. + +**2. Disk space on runners**: Building both base and app images in one job exhausted GitHub runner disk space. We added conditional cleanup—aggressive cleanup only when base image needs building. + +**3. Test result extraction from failed builds**: `docker build` exits non-zero when tests fail, so the container never gets created. The fix is `--target builder` to build just that stage, ignoring the runtime stage that depends on test success. + +## What's Next + +This setup handles unit tests, but integration tests—especially for Shiny apps with browser interactions—are a different beast. That's Part 3: running headless Chrome in Docker for `shinytest2` without wanting to throw your laptop out the window. + +--- + +*This is Part 2 of a series on Docker optimization for R Shiny applications. [Part 1](docker-optimization.html) covers multistage builds and layer caching fundamentals.* diff --git a/renv.lock b/renv.lock index 5b59848..b38d8b7 100644 --- a/renv.lock +++ b/renv.lock @@ -90,6 +90,28 @@ "Repository": "https://packagemanager.posit.co/cran/latest", "Encoding": "UTF-8" }, + "brio": { + "Package": "brio", + "Version": "1.1.5", + "Source": "Repository", + "Title": "Basic R Input Output", + "Authors@R": "c( person(\"Jim\", \"Hester\", , \"james.f.hester@gmail.com\", role = \"aut\", comment = c(ORCID = \"0000-0002-2739-7082\")), person(\"Gábor\", \"Csárdi\", , \"csardi.gabor@gmail.com\", role = c(\"aut\", \"cre\")), person(\"Posit Software, PBC\", role = c(\"cph\", \"fnd\")) )", + "Description": "Functions to handle basic input output, these functions always read and write UTF-8 (8-bit Unicode Transformation Format) files and provide more explicit control over line endings.", + "License": "MIT + file LICENSE", + "URL": "https://brio.r-lib.org, https://github.com/r-lib/brio", + "BugReports": "https://github.com/r-lib/brio/issues", + "Depends": [ + "R (>= 3.6)" + ], + "Suggests": [ + "covr", + "testthat (>= 3.0.0)" + ], + "NeedsCompilation": "yes", + "Author": "Jim Hester [aut] (ORCID: ), Gábor Csárdi [aut, cre], Posit Software, PBC [cph, fnd]", + "Maintainer": "Gábor Csárdi ", + "Repository": "https://packagemanager.posit.co/cran/latest" + }, "bslib": { "Package": "bslib", "Version": "0.9.0", @@ -175,6 +197,40 @@ "Maintainer": "Winston Chang ", "Repository": "https://packagemanager.posit.co/cran/latest" }, + "callr": { + "Package": "callr", + "Version": "3.7.6", + "Source": "Repository", + "Title": "Call R from R", + "Authors@R": "c( person(\"Gábor\", \"Csárdi\", , \"csardi.gabor@gmail.com\", role = c(\"aut\", \"cre\", \"cph\")), person(\"Winston\", \"Chang\", role = \"aut\"), person(\"Posit Software, PBC\", role = c(\"cph\", \"fnd\")), person(\"Ascent Digital Services\", role = c(\"cph\", \"fnd\")) )", + "Description": "It is sometimes useful to perform a computation in a separate R process, without affecting the current R process at all. This packages does exactly that.", + "License": "MIT + file LICENSE", + "URL": "https://callr.r-lib.org, https://github.com/r-lib/callr", + "BugReports": "https://github.com/r-lib/callr/issues", + "Depends": [ + "R (>= 3.4)" + ], + "Imports": [ + "processx (>= 3.6.1)", + "R6", + "utils" + ], + "Suggests": [ + "asciicast (>= 2.3.1)", + "cli (>= 1.1.0)", + "mockery", + "ps", + "rprojroot", + "spelling", + "testthat (>= 3.2.0)", + "withr (>= 2.3.0)" + ], + "NeedsCompilation": "no", + "Author": "Gábor Csárdi [aut, cre, cph], Winston Chang [aut], Posit Software, PBC [cph, fnd], Ascent Digital Services [cph, fnd]", + "Maintainer": "Gábor Csárdi ", + "Repository": "https://packagemanager.posit.co/cran/latest", + "Language": "en-US" + }, "cli": { "Package": "cli", "Version": "3.6.5", @@ -275,12 +331,76 @@ "Maintainer": "Dirk Eddelbuettel ", "Repository": "https://packagemanager.posit.co/cran/latest" }, + "desc": { + "Package": "desc", + "Version": "1.4.3", + "Source": "Repository", + "Title": "Manipulate DESCRIPTION Files", + "Authors@R": "c( person(\"Gábor\", \"Csárdi\", , \"csardi.gabor@gmail.com\", role = c(\"aut\", \"cre\")), person(\"Kirill\", \"Müller\", , \"kirill@cynkra.com\", role = \"aut\"), person(\"Jim\", \"Hester\", , \"james.f.hester@gmail.com\", role = \"aut\"), person(\"Maëlle\", \"Salmon\", role = \"ctb\"), person(\"Posit Software, PBC\", role = c(\"cph\", \"fnd\")) )", + "Description": "Tools to read, write, create, and manipulate DESCRIPTION files. It is intended for packages that create or manipulate other packages.", + "License": "MIT + file LICENSE", + "URL": "https://desc.r-lib.org/, https://github.com/r-lib/desc", + "BugReports": "https://github.com/r-lib/desc/issues", + "Depends": [ + "R (>= 3.4)" + ], + "Imports": [ + "cli", + "R6", + "utils" + ], + "Suggests": [ + "callr", + "covr", + "gh", + "spelling", + "testthat", + "whoami", + "withr" + ], + "NeedsCompilation": "no", + "Author": "Gábor Csárdi [aut, cre], Kirill Müller [aut], Jim Hester [aut], Maëlle Salmon [ctb], Posit Software, PBC [cph, fnd]", + "Maintainer": "Gábor Csárdi ", + "Repository": "https://packagemanager.posit.co/cran/latest", + "Language": "en-US" + }, "dplyr": { "Package": "dplyr", "Version": "1.1.4", "Source": "Repository", "Repository": "CRAN" }, + "evaluate": { + "Package": "evaluate", + "Version": "1.0.5", + "Source": "Repository", + "Title": "Parsing and Evaluation Tools that Provide More Details than the Default", + "Authors@R": "c( person(\"Hadley\", \"Wickham\", , \"hadley@posit.co\", role = c(\"aut\", \"cre\")), person(\"Yihui\", \"Xie\", role = \"aut\"), person(\"Michael\", \"Lawrence\", role = \"ctb\"), person(\"Thomas\", \"Kluyver\", role = \"ctb\"), person(\"Jeroen\", \"Ooms\", role = \"ctb\"), person(\"Barret\", \"Schloerke\", role = \"ctb\"), person(\"Adam\", \"Ryczkowski\", role = \"ctb\"), person(\"Hiroaki\", \"Yutani\", role = \"ctb\"), person(\"Michel\", \"Lang\", role = \"ctb\"), person(\"Karolis\", \"Koncevičius\", role = \"ctb\"), person(\"Posit Software, PBC\", role = c(\"cph\", \"fnd\")) )", + "Description": "Parsing and evaluation tools that make it easy to recreate the command line behaviour of R.", + "License": "MIT + file LICENSE", + "URL": "https://evaluate.r-lib.org/, https://github.com/r-lib/evaluate", + "BugReports": "https://github.com/r-lib/evaluate/issues", + "Depends": [ + "R (>= 3.6.0)" + ], + "Suggests": [ + "callr", + "covr", + "ggplot2 (>= 3.3.6)", + "lattice", + "methods", + "pkgload", + "ragg (>= 1.4.0)", + "rlang (>= 1.1.5)", + "knitr", + "testthat (>= 3.0.0)", + "withr" + ], + "NeedsCompilation": "no", + "Author": "Hadley Wickham [aut, cre], Yihui Xie [aut], Michael Lawrence [ctb], Thomas Kluyver [ctb], Jeroen Ooms [ctb], Barret Schloerke [ctb], Adam Ryczkowski [ctb], Hiroaki Yutani [ctb], Michel Lang [ctb], Karolis Koncevičius [ctb], Posit Software, PBC [cph, fnd]", + "Maintainer": "Hadley Wickham ", + "Repository": "https://packagemanager.posit.co/cran/latest" + }, "fastmap": { "Package": "fastmap", "Version": "1.2.0", @@ -755,6 +875,137 @@ "Maintainer": "Gábor Csárdi ", "Repository": "https://packagemanager.posit.co/cran/latest" }, + "pkgload": { + "Package": "pkgload", + "Version": "1.4.1", + "Source": "Repository", + "Title": "Simulate Package Installation and Attach", + "Authors@R": "c( person(\"Hadley\", \"Wickham\", , \"hadley@posit.co\", role = \"aut\"), person(\"Winston\", \"Chang\", , \"winston@posit.co\", role = \"aut\"), person(\"Jim\", \"Hester\", , \"james.f.hester@gmail.com\", role = \"aut\"), person(\"Lionel\", \"Henry\", , \"lionel@posit.co\", role = c(\"aut\", \"cre\")), person(\"Posit Software, PBC\", role = c(\"cph\", \"fnd\")), person(\"R Core team\", role = \"ctb\", comment = \"Some namespace and vignette code extracted from base R\") )", + "Description": "Simulates the process of installing a package and then attaching it. This is a key part of the 'devtools' package as it allows you to rapidly iterate while developing a package.", + "License": "MIT + file LICENSE", + "URL": "https://pkgload.r-lib.org, https://github.com/r-lib/pkgload", + "BugReports": "https://github.com/r-lib/pkgload/issues", + "Depends": [ + "R (>= 3.4.0)" + ], + "Imports": [ + "cli (>= 3.3.0)", + "desc", + "fs", + "glue", + "lifecycle", + "methods", + "pkgbuild", + "processx", + "rlang (>= 1.1.1)", + "rprojroot", + "utils" + ], + "Suggests": [ + "bitops", + "jsonlite", + "mathjaxr", + "pak", + "Rcpp", + "remotes", + "rstudioapi", + "testthat (>= 3.2.1.1)", + "usethis", + "withr" + ], + "NeedsCompilation": "no", + "Author": "Hadley Wickham [aut], Winston Chang [aut], Jim Hester [aut], Lionel Henry [aut, cre], Posit Software, PBC [cph, fnd], R Core team [ctb] (Some namespace and vignette code extracted from base R)", + "Maintainer": "Lionel Henry ", + "Repository": "https://packagemanager.posit.co/cran/latest" + }, + "praise": { + "Package": "praise", + "Version": "1.0.0", + "Source": "Repository", + "Title": "Praise Users", + "Authors@R": "c( person(\"Gabor\", \"Csardi\", , \"csardi.gabor@gmail.com\", role = c(\"aut\", \"cre\")), person(\"Sindre\", \"Sorhus\", role = \"ctb\") )", + "Description": "Build friendly R packages that praise their users if they have done something good, or they just need it to feel better.", + "License": "MIT + file LICENSE", + "URL": "https://github.com/gaborcsardi/praise", + "BugReports": "https://github.com/gaborcsardi/praise/issues", + "Suggests": [ + "testthat" + ], + "NeedsCompilation": "no", + "Author": "Gabor Csardi [aut, cre], Sindre Sorhus [ctb]", + "Maintainer": "Gabor Csardi ", + "Repository": "https://packagemanager.posit.co/cran/latest" + }, + "processx": { + "Package": "processx", + "Version": "3.8.6", + "Source": "Repository", + "Title": "Execute and Control System Processes", + "Authors@R": "c( person(\"Gábor\", \"Csárdi\", , \"csardi.gabor@gmail.com\", role = c(\"aut\", \"cre\", \"cph\")), person(\"Winston\", \"Chang\", role = \"aut\"), person(\"Posit Software, PBC\", role = c(\"cph\", \"fnd\")), person(\"Ascent Digital Services\", role = c(\"cph\", \"fnd\")) )", + "Description": "Tools to run system processes in the background. It can check if a background process is running; wait on a background process to finish; get the exit status of finished processes; kill background processes. It can read the standard output and error of the processes, using non-blocking connections. 'processx' can poll a process for standard output or error, with a timeout. It can also poll several processes at once.", + "License": "MIT + file LICENSE", + "URL": "https://processx.r-lib.org, https://github.com/r-lib/processx", + "BugReports": "https://github.com/r-lib/processx/issues", + "Depends": [ + "R (>= 3.4.0)" + ], + "Imports": [ + "ps (>= 1.2.0)", + "R6", + "utils" + ], + "Suggests": [ + "callr (>= 3.7.3)", + "cli (>= 3.3.0)", + "codetools", + "covr", + "curl", + "debugme", + "parallel", + "rlang (>= 1.0.2)", + "testthat (>= 3.0.0)", + "webfakes", + "withr" + ], + "NeedsCompilation": "yes", + "Author": "Gábor Csárdi [aut, cre, cph], Winston Chang [aut], Posit Software, PBC [cph, fnd], Ascent Digital Services [cph, fnd]", + "Maintainer": "Gábor Csárdi ", + "Repository": "https://packagemanager.posit.co/cran/latest" + }, + "ps": { + "Package": "ps", + "Version": "1.9.1", + "Source": "Repository", + "Title": "List, Query, Manipulate System Processes", + "Authors@R": "c( person(\"Jay\", \"Loden\", role = \"aut\"), person(\"Dave\", \"Daeschler\", role = \"aut\"), person(\"Giampaolo\", \"Rodola'\", role = \"aut\"), person(\"Gábor\", \"Csárdi\", , \"csardi.gabor@gmail.com\", role = c(\"aut\", \"cre\")), person(\"Posit Software, PBC\", role = c(\"cph\", \"fnd\")) )", + "Description": "List, query and manipulate all system processes, on 'Windows', 'Linux' and 'macOS'.", + "License": "MIT + file LICENSE", + "URL": "https://github.com/r-lib/ps, https://ps.r-lib.org/", + "BugReports": "https://github.com/r-lib/ps/issues", + "Depends": [ + "R (>= 3.4)" + ], + "Imports": [ + "utils" + ], + "Suggests": [ + "callr", + "covr", + "curl", + "pillar", + "pingr", + "processx (>= 3.1.0)", + "R6", + "rlang", + "testthat (>= 3.0.0)", + "webfakes", + "withr" + ], + "NeedsCompilation": "yes", + "Author": "Jay Loden [aut], Dave Daeschler [aut], Giampaolo Rodola' [aut], Gábor Csárdi [aut, cre], Posit Software, PBC [cph, fnd]", + "Maintainer": "Gábor Csárdi ", + "Repository": "https://packagemanager.posit.co/cran/latest" + }, "promises": { "Package": "promises", "Version": "1.5.0", @@ -1012,6 +1263,63 @@ "NeedsCompilation": "yes", "Repository": "https://packagemanager.posit.co/cran/latest" }, + "testthat": { + "Package": "testthat", + "Version": "3.2.1.1", + "Source": "Repository", + "Title": "Unit Testing for R", + "Authors@R": "c( person(\"Hadley\", \"Wickham\", , \"hadley@posit.co\", role = c(\"aut\", \"cre\")), person(\"Posit Software, PBC\", role = c(\"cph\", \"fnd\")), person(\"R Core team\", role = \"ctb\", comment = \"Implementation of utils::recover()\") )", + "Description": "Software testing is important, but, in part because it is frustrating and boring, many of us avoid it. 'testthat' is a testing framework for R that is easy to learn and use, and integrates with your existing 'workflow'.", + "License": "MIT + file LICENSE", + "URL": "https://testthat.r-lib.org, https://github.com/r-lib/testthat", + "BugReports": "https://github.com/r-lib/testthat/issues", + "Depends": [ + "R (>= 3.6.0)" + ], + "Imports": [ + "brio (>= 1.1.3)", + "callr (>= 3.7.3)", + "cli (>= 3.6.1)", + "desc (>= 1.4.2)", + "digest (>= 0.6.33)", + "evaluate (>= 0.21)", + "jsonlite", + "lifecycle (>= 1.0.3)", + "magrittr (>= 2.0.3)", + "methods", + "pkgload (>= 1.3.2.1)", + "praise", + "processx (>= 3.8.2)", + "ps (>= 1.7.5)", + "R6 (>= 2.5.1)", + "rlang (>= 1.1.1)", + "utils", + "waldo (>= 0.5.1)", + "withr (>= 2.5.0)" + ], + "Suggests": [ + "covr", + "curl (>= 0.9.5)", + "diffviewer (>= 0.1.0)", + "knitr", + "mockery", + "rmarkdown", + "rstudioapi", + "shiny", + "usethis", + "vctrs (>= 0.1.0)", + "xml2" + ], + "VignetteBuilder": "knitr", + "Config/Needs/website": "tidyverse/tidytemplate", + "Config/testthat/edition": "3", + "Config/testthat/parallel": "true", + "Config/testthat/start-first": "watcher, parallel*", + "Encoding": "UTF-8", + "RoxygenNote": "7.3.1", + "NeedsCompilation": "yes", + "Repository": "CRAN" + }, "withr": { "Package": "withr", "Version": "3.0.2", @@ -1050,6 +1358,39 @@ "Maintainer": "Lionel Henry ", "Repository": "https://packagemanager.posit.co/cran/latest" }, + "waldo": { + "Package": "waldo", + "Version": "0.6.2", + "Source": "Repository", + "Title": "Find Differences Between R Objects", + "Authors@R": "c( person(\"Hadley\", \"Wickham\", , \"hadley@posit.co\", role = c(\"aut\", \"cre\")), person(\"Posit Software, PBC\", role = c(\"cph\", \"fnd\")) )", + "Description": "Compare complex R objects and reveal the key differences. Designed particularly for use in testing packages where being able to quickly isolate key differences makes understanding test failures much easier.", + "License": "MIT + file LICENSE", + "URL": "https://waldo.r-lib.org, https://github.com/r-lib/waldo", + "BugReports": "https://github.com/r-lib/waldo/issues", + "Depends": [ + "R (>= 4.0)" + ], + "Imports": [ + "cli", + "diffobj (>= 0.3.4)", + "glue", + "methods", + "rlang (>= 1.1.0)" + ], + "Suggests": [ + "bit64", + "R6", + "S7", + "testthat (>= 3.0.0)", + "withr", + "xml2" + ], + "NeedsCompilation": "no", + "Author": "Hadley Wickham [aut, cre], Posit Software, PBC [cph, fnd]", + "Maintainer": "Hadley Wickham ", + "Repository": "https://packagemanager.posit.co/cran/latest" + }, "xtable": { "Package": "xtable", "Version": "1.8-4", diff --git a/tests/testthat/test-app.R b/tests/testthat/test-app.R new file mode 100644 index 0000000..67dcbe6 --- /dev/null +++ b/tests/testthat/test-app.R @@ -0,0 +1,116 @@ +# Tests for Shiny Docker Demo App +# These tests run during Docker build - failures abort the build + +library(testthat) + +# ============================================================================ +# Package Loading Tests +# ============================================================================ +# Verify all required packages are installed and loadable + +test_that("required packages are installed", { + expect_true(requireNamespace("shiny", quietly = TRUE)) + expect_true(requireNamespace("ggplot2", quietly = TRUE)) + expect_true(requireNamespace("dplyr", quietly = TRUE)) + expect_true(requireNamespace("DT", quietly = TRUE)) +}) + +test_that("packages load without error", { + expect_no_error(library(shiny)) + expect_no_error(library(ggplot2)) + expect_no_error(library(dplyr)) + expect_no_error(library(DT)) +}) + +# ============================================================================ +# Data Preparation Tests +# ============================================================================ +# Verify the mtcars data preparation works correctly + +test_that("mtcars data is available", { + expect_true(exists("mtcars")) + expect_equal(nrow(mtcars), 32) + expect_equal(ncol(mtcars), 11) +}) + +test_that("cars_data preparation works", { + library(dplyr) + + cars_data <- mtcars %>% + mutate(car_name = rownames(mtcars)) %>% + select(car_name, everything()) + + # Should have 32 rows and 12 columns (11 + car_name) + + expect_equal(nrow(cars_data), 32) + expect_equal(ncol(cars_data), 12) + + # car_name should be first column + + expect_equal(names(cars_data)[1], "car_name") + + # Should contain expected car names + expect_true("Mazda RX4" %in% cars_data$car_name) + expect_true("Toyota Corolla" %in% cars_data$car_name) +}) + +# ============================================================================ +# Filter Logic Tests +# ============================================================================ +# Verify the MPG filter works correctly + +test_that("MPG filter produces correct results", { + library(dplyr) + + cars_data <- mtcars %>% + mutate(car_name = rownames(mtcars)) + + # Filter for MPG >= 20 + filtered <- cars_data %>% filter(mpg >= 20) + + # All remaining cars should have MPG >= 20 + expect_true(all(filtered$mpg >= 20)) + + # Toyota Corolla (33.9 mpg) should be included + expect_true("Toyota Corolla" %in% filtered$car_name) + + # Cadillac Fleetwood (10.4 mpg) should be excluded + expect_false("Cadillac Fleetwood" %in% filtered$car_name) +}) + +test_that("extreme filter values work", { + library(dplyr) + + cars_data <- mtcars %>% + mutate(car_name = rownames(mtcars)) + + # Filter with minimum value - should return all cars + all_cars <- cars_data %>% filter(mpg >= 10) + expect_equal(nrow(all_cars), 32) + + # Filter with maximum value - should return few cars + high_mpg <- cars_data %>% filter(mpg >= 30) + expect_lt(nrow(high_mpg), 10) + expect_gt(nrow(high_mpg), 0) +}) + +# ============================================================================ +# Data Transformation Tests +# ============================================================================ +# Verify data transformations for display + +test_that("transmission values transform correctly", { + am_auto <- ifelse(0 == 0, "Automatic", "Manual") + am_manual <- ifelse(1 == 0, "Automatic", "Manual") + + expect_equal(am_auto, "Automatic") + expect_equal(am_manual, "Manual") +}) + +test_that("engine type values transform correctly", { + vs_v <- ifelse(0 == 0, "V-shaped", "Straight") + vs_s <- ifelse(1 == 0, "V-shaped", "Straight") + + expect_equal(vs_v, "V-shaped") + expect_equal(vs_s, "Straight") +})