Building WSL-UI: Mock Mode and Fake Distros

Published: (January 17, 2026 at 09:40 PM EST)
5 min read
Source: Dev.to

Source: Dev.to

Architectural Decision: Mock Mode for WSL‑UI

One of the first architectural decisions I made with WSL‑UI was to build a complete mock mode.
It isn’t only for automated testing – although that’s essential – but also for day‑to‑day development.

Why?

  • I didn’t want to accidentally delete my real WSL distributions while debugging.
  • I needed to test scenarios that are hard to reproduce with real distros (e.g., network timeouts, corrupted registry entries).

The key insight came from Domain‑Driven Design: the Anti‑Corruption Layer pattern.
Instead of calling wsl.exe directly from my command handlers, I created an abstraction layer that can be swapped out at runtime.


Trait Definitions (Rust)

pub trait WslCommandExecutor: Send + Sync {
    fn list_distributions(&self) -> Result<(), WslError>;
    fn start_distribution(&self, name: &str) -> Result<(), WslError>;
    fn stop_distribution(&self, name: &str) -> Result<(), WslError>;
    fn terminate_distribution(&self, name: &str) -> Result<(), WslError>;
    fn import_distribution(
        &self,
        name: &str,
        path: &Path,
        location: &Path,
    ) -> Result<(), WslError>;
    // …more operations
}

pub trait ResourceMonitor: Send + Sync {
    fn get_memory_usage(&self, name: &str) -> Result<u64, WslError>;
    fn get_cpu_percentage(&self, name: &str) -> Result<f64, WslError>;
    fn get_vhdx_size(&self, name: &str) -> Result<u64, WslError>;
    fn get_registry_info(&self, name: &str) -> Result<RegistryInfo, WslError>;
}

pub trait TerminalExecutor: Send + Sync {
    fn open_terminal(&self, name: &str) -> Result<(), WslError>;
    fn execute_command(&self, name: &str, command: &str) -> Result<CommandOutput, WslError>;
}
  • Real implementations call wsl.exe, read the Windows Registry, and interact with Windows Terminal.
  • Mock implementations keep internal state and return deterministic, controllable responses.

Default Mock Distributions

When mock mode is active, the app starts with seven fake distributions:

NameVersionStateInstall Source
UbuntuWSL2RunningMicrosoft Store
DebianWSL2StoppedLXC Container
AlpineWSL2StoppedContainer Import
Ubuntu‑22.04WSL2RunningDownload
FedoraWSL2RunningDownload
ArchWSL2StoppedDownload
Ubuntu‑legacyWSL1StoppedLegacy

Each distribution has simulated resource usage:

let (mock_memory, mock_cpu) = match distro {
    "Ubuntu" => (512_000_000, 2.5),        // ~512 MiB, 2.5 % CPU
    "Ubuntu-22.04" => (384_000_000, 1.8),  // ~384 MiB, 1.8 % CPU
    "Debian" => (256_000_000, 0.5),       // ~256 MiB, 0.5 % CPU
    "Alpine" => (64_000_000, 0.2),        // ~64 MiB, 0.2 % CPU
    "Fedora" => (196_000_000, 1.2),       // ~196 MiB, 1.2 % CPU
    _ => (128_000_000, 0.3),              // Default values
};
  • Registry entries contain realistic GUIDs, paths, and package information.
  • Disk sizes range from 500 MiB to 8 GiB.
  • The mock even simulates physical disks (a 500 GB SSD and a 1 TB HDD with partitions) for the disk‑mounting feature.

The mock state is dynamic:

  • Starting a distro changes its state to Running.
  • Stopping it reverts to Stopped.
  • Creating a new distro adds it to the list; deleting removes it.

Mock Executor Skeleton

pub struct MockWslExecutor {
    distributions: Arc<Mutex<HashMap<String, MockDistribution>>>,
    default_distribution: Arc<Mutex<Option<String>>>,
    error_simulation: Arc<Mutex<Option<ErrorSimulation>>>,
}

impl MockWslExecutor {
    pub fn new() -> Self {
        let mut distros = HashMap::new();

        // Initialise with the 7 default distributions
        distros.insert(
            "Ubuntu".to_string(),
            MockDistribution {
                guid: generate_guid(),
                name: "Ubuntu".to_string(),
                state: DistroState::Running,
                version: WslVersion::Wsl2,
                // …
            },
        );
        // …more distros

        Self {
            distributions: Arc::new(Mutex::new(distros)),
            default_distribution: Arc::new(Mutex::new(Some("Ubuntu".to_string()))),
            error_simulation: Arc::new(Mutex::new(None)),
        }
    }
}

The Arc<Mutex<…>> pattern guarantees thread safety – Tauri commands may be invoked from multiple threads, and the mock state must stay consistent.


Simulating Errors on Demand

Testing happy‑path flows is trivial; testing error handling is only easy when you can force errors.

pub struct ErrorSimulation {
    pub operation: String,
    pub error_type: ErrorType,
    pub delay_ms: u64,
}

pub enum ErrorType {
    Timeout,
    CommandFailed,
    NotFound,
    Cancelled,
}

From the frontend (or E2E tests) you can schedule a failure:

await invoke('set_mock_error', {
    operation: 'start_distribution',
    errorType: 'timeout',
    delayMs: 5000,
});

// The next start_distribution call will timeout after 5 s
await invoke('start_distribution', { name: 'Ubuntu' });

This lets me verify:

  • Progress dialogs during slow operations
  • Error‑notification UI
  • Retry logic
  • Graceful degradation

Enabling Mock Mode

Mock mode is toggled via environment variables:

# Either of these activates mock mode
WSL_MOCK=1
WSL_UI_MOCK_MODE=1

On startup the app checks the flag and wires the appropriate implementations:

fn init_executors() {
    if crate::utils::is_mock_mode() {
        // Mock implementations
        let wsl_mock = Arc::new(MockWslExecutor::new());
        WSL_EXECUTOR.get_or_init(|| wsl_mock.clone());

        let resource_mock = MockResourceMonitor::with_wsl_mock(wsl_mock.clone());
        RESOURCE_MONITOR.get_or_init(|| Arc::new(resource_mock));

        let terminal_mock = MockTerminalExecutor::new();
        TERMINAL_EXECUTOR.get_or_init(|| Arc::new(terminal_mock));
    } else {
        // Real implementations
        WSL_EXECUTOR.get_or_init(|| Arc::new(RealWslExecutor));
        RESOURCE_MONITOR.get_or_init(|| Arc::new(RealResourceMonitor));
        TERMINAL_EXECUTOR.get_or_init(|| Arc::new(RealTerminalExecutor));
    }
}

Summary

  • Mock mode protects real WSL installations while providing a rich, controllable test environment.
  • The Anti‑Corruption Layer (traits + concrete implementations) makes swapping between real and mock code trivial.
  • With stateful mocks, error simulation, and environment‑driven activation, development, manual testing, and CI pipelines become faster, safer, and more reliable.

Frontend Mock Support

The mock mode isn’t just for the backend. The frontend exposes Zustand stores on the window object during E2E tests:

// In development/test mode
if (import.meta.env.DEV || import.meta.env.MODE === 'test') {
  (window as any).__distroStore = useDistroStore;
  (window as any).__notificationStore = useNotificationStore;
}

This lets E2E tests directly inspect and manipulate application state:

// In a WebdriverIO test
const store = await browser.execute(() => window.__distroStore.getState());
expect(store.distributions).toHaveLength(7);

// Reset to initial state between tests
await browser.execute(() => window.__distroStore.getState().reset());

Was the effort worth it?

Absolutely. Building the mock mode took significant effort — probably 15‑20 % of the total project time — but it paid off in many ways:

  • Faster development – No waiting for real WSL operations; starting a distribution takes milliseconds instead of seconds.
  • Safe experimentation – Destructive operations (delete, format) can be tested without risk.
  • Reproducible tests – E2E tests run against an identical initial state every time.
  • Offline development – No need for actual WSL distributions to be installed.
  • Edge‑case coverage – Easy to test scenarios like “what if the user has 50 distributions?”.
  • CI/CD friendly – Tests run in GitHub Actions on a clean Windows runner with no WSL setup required.

Lessons Learned

If I were doing this again, I’d start with the mock mode even earlier. The abstraction layer pays dividends throughout development, not just in testing.

Things I’d do differently

  1. More realistic timing – The mock is too fast. Real WSL operations have noticeable latency. Adding configurable delays would make the development experience more representative.
  2. Persistence option – Currently the mock resets on app restart. An option to persist mock state to a file would be useful for longer testing sessions.
  3. Fuzz testing – Random operation sequences to find edge cases. The infrastructure is there; I just need to write the tests.

What’s next?

The next post in this series will dive into the gnarly details of renaming distributions, including the Windows Registry changes required to make it work properly.


Get WSL‑UI

WSL‑UI is open source and available on:

Back to Blog

Related posts

Read more »

NgRx Toolkit v21

NgRx Toolkit v21 The NgRx Toolkit originates from a time when the SignalStore was not even marked stable. In those early days, community requests for various f...