mirror of
https://github.com/wiremock/WireMock.Net.git
synced 2026-04-26 18:59:02 +02:00
ws1
This commit is contained in:
676
copilot/WebSockets/v1/WEBSOCKET_PATTERNS_BEST_PRACTICES.md
Normal file
676
copilot/WebSockets/v1/WEBSOCKET_PATTERNS_BEST_PRACTICES.md
Normal file
@@ -0,0 +1,676 @@
|
||||
# WebSocket Design Patterns - Visual Guide & Best Practices
|
||||
|
||||
## Overview
|
||||
|
||||
This document provides visual examples and best practices for using WebSocket support in WireMock.Net following the established fluent interface patterns.
|
||||
|
||||
---
|
||||
|
||||
## Part 1: Pattern Evolution in WireMock.Net
|
||||
|
||||
### HTTP Request Matching Pattern
|
||||
|
||||
```
|
||||
┌─────────────┐
|
||||
│ Request. │ Fluent methods return IRequestBuilder
|
||||
│ Create() │ for chaining
|
||||
│ │
|
||||
├─────────────┤
|
||||
│ WithPath │
|
||||
├─────────────┤
|
||||
│ WithHeader │
|
||||
├─────────────┤
|
||||
│ UsingGet() │ Each partial class file handles
|
||||
├─────────────┤ one concern (path, headers, method)
|
||||
│ WithParam │
|
||||
├─────────────┤
|
||||
│ WithBody │
|
||||
└─────────────┘
|
||||
```
|
||||
|
||||
### HTTP Response Building Pattern
|
||||
|
||||
```
|
||||
┌──────────────┐
|
||||
│ Response. │
|
||||
│ Create() │
|
||||
│ │
|
||||
├──────────────┤
|
||||
│ WithStatus │
|
||||
├──────────────┤
|
||||
│ WithHeader │
|
||||
├──────────────┤
|
||||
│ WithBody │ Returns IResponseBuilder
|
||||
├──────────────┤ for chaining
|
||||
│ WithDelay │
|
||||
├──────────────┤
|
||||
│ WithTransf. │ Transformer for template
|
||||
├──────────────┤ substitution
|
||||
│ WithCallback │ Dynamic responses
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
### WebSocket Extension Pattern (New)
|
||||
|
||||
```
|
||||
┌──────────────────────┐
|
||||
│ Request.Create() │
|
||||
├──────────────────────┤
|
||||
│ WithWebSocketPath │
|
||||
├──────────────────────┤
|
||||
│ WithWebSocketSubprot │ Extends request builder
|
||||
├──────────────────────┤ with WebSocket-specific
|
||||
│ WithWebSocketOrigin │ matching
|
||||
└──────────────────────┘
|
||||
│
|
||||
↓
|
||||
┌──────────────────────┐
|
||||
│ Response.Create() │
|
||||
├──────────────────────┤
|
||||
│ WithWebSocket() │
|
||||
│ ├─ WithMessage() │ Fluent builder for
|
||||
│ ├─ WithJsonMessage() │ composing messages
|
||||
│ └─ WithTransformer() │
|
||||
├──────────────────────┤
|
||||
│ WithWebSocketCallback│ Dynamic message gen
|
||||
├──────────────────────┤
|
||||
│ WithWebSocketClose() │ Graceful shutdown
|
||||
└──────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 2: Usage Pattern Comparison
|
||||
|
||||
### Pattern 1: Static Messages
|
||||
|
||||
**Analogy**: Pre-recorded HTTP responses
|
||||
|
||||
```csharp
|
||||
// HTTP (existing)
|
||||
server.Given(Request.Create().WithPath("/api/users"))
|
||||
.RespondWith(Response.Create()
|
||||
.WithStatusCode(200)
|
||||
.WithBodyAsJson(new { id = 1, name = "John" })
|
||||
);
|
||||
|
||||
// WebSocket (new - sequential messages)
|
||||
server.Given(Request.Create().WithWebSocketPath("/api/users/stream"))
|
||||
.RespondWith(Response.Create()
|
||||
.WithWebSocket(ws => ws
|
||||
.WithJsonMessage(new { type = "connected" }, delayMs: 0)
|
||||
.WithJsonMessage(new { id = 1, name = "John" }, delayMs: 500)
|
||||
.WithJsonMessage(new { id = 2, name = "Jane" }, delayMs: 1000)
|
||||
.WithClose(1000, "Stream complete")
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
### Pattern 2: Dynamic Content (Request-Based)
|
||||
|
||||
**Analogy**: Response callbacks
|
||||
|
||||
```csharp
|
||||
// HTTP (existing)
|
||||
server.Given(Request.Create().WithPath("/api/echo"))
|
||||
.RespondWith(Response.Create()
|
||||
.WithCallback(request =>
|
||||
{
|
||||
return new ResponseMessage
|
||||
{
|
||||
BodyData = new BodyData
|
||||
{
|
||||
BodyAsString = $"Echo: {request.Body}",
|
||||
DetectedBodyType = BodyType.String
|
||||
},
|
||||
StatusCode = 200
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// WebSocket (new - message stream from request)
|
||||
server.Given(Request.Create().WithWebSocketPath("/echo"))
|
||||
.RespondWith(Response.Create()
|
||||
.WithWebSocketCallback(async request =>
|
||||
{
|
||||
// Generate messages based on request context
|
||||
return new[]
|
||||
{
|
||||
new WebSocketMessage
|
||||
{
|
||||
BodyAsString = $"Echo: {request.Body}",
|
||||
DelayMs = 100,
|
||||
IsText = true
|
||||
}
|
||||
};
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
### Pattern 3: Templating (Dynamic Values)
|
||||
|
||||
**Analogy**: Handlebars/Scriban transformers
|
||||
|
||||
```csharp
|
||||
// HTTP (existing)
|
||||
server.Given(Request.Create().WithPath("/api/user"))
|
||||
.RespondWith(Response.Create()
|
||||
.WithBodyAsJson(new {
|
||||
username = "{{request.headers.X-User-Name}}",
|
||||
timestamp = "{{now}}"
|
||||
})
|
||||
.WithTransformer()
|
||||
);
|
||||
|
||||
// WebSocket (new - template in messages)
|
||||
server.Given(Request.Create().WithWebSocketPath("/notifications"))
|
||||
.RespondWith(Response.Create()
|
||||
.WithWebSocket(ws => ws
|
||||
.WithJsonMessage(new {
|
||||
user = "{{request.headers.X-User-Name}}",
|
||||
connected = "{{now}}"
|
||||
})
|
||||
.WithJsonMessage(new {
|
||||
message = "Hello {{request.headers.X-User-Name}}"
|
||||
}, delayMs: 1000)
|
||||
.WithTransformer()
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
### Pattern 4: Metadata (Scenario State)
|
||||
|
||||
**Analogy**: Scenario state management
|
||||
|
||||
```csharp
|
||||
// HTTP (existing)
|
||||
server.Given(Request.Create().WithPath("/login"))
|
||||
.InScenario("UserWorkflow")
|
||||
.WillSetStateTo("LoggedIn")
|
||||
.RespondWith(Response.Create()
|
||||
.WithStatusCode(200)
|
||||
.WithBodyAsJson(new { success = true })
|
||||
);
|
||||
|
||||
// WebSocket (new - state in WebSocket flow)
|
||||
server.Given(Request.Create().WithWebSocketPath("/chat"))
|
||||
.InScenario("ChatSession")
|
||||
.WhenStateIs("LoggedIn")
|
||||
.WillSetStateTo("ChatActive")
|
||||
.RespondWith(Response.Create()
|
||||
.WithWebSocket(ws => ws
|
||||
.WithJsonMessage(new { type = "welcome" })
|
||||
.WithJsonMessage(new { type = "ready" })
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
### Pattern 5: Extensions (Webhooks)
|
||||
|
||||
**Analogy**: Side-effects during request handling
|
||||
|
||||
```csharp
|
||||
// HTTP (existing) - Trigger external webhook
|
||||
server.Given(Request.Create().WithPath("/process"))
|
||||
.WithWebhook(new Webhook
|
||||
{
|
||||
Request = new WebhookRequest
|
||||
{
|
||||
Url = "http://external-service/notify",
|
||||
Method = "post",
|
||||
BodyData = new BodyData { BodyAsString = "Processing..." }
|
||||
}
|
||||
})
|
||||
.RespondWith(Response.Create().WithStatusCode(200));
|
||||
|
||||
// WebSocket (new) - Webhook triggered by connection
|
||||
server.Given(Request.Create().WithWebSocketPath("/events"))
|
||||
.WithWebhook(new Webhook
|
||||
{
|
||||
Request = new WebhookRequest
|
||||
{
|
||||
Url = "http://audit-log/event",
|
||||
Method = "post",
|
||||
BodyData = new BodyData {
|
||||
BodyAsString = "WebSocket connected: {{request.url}}"
|
||||
}
|
||||
}
|
||||
})
|
||||
.RespondWith(Response.Create()
|
||||
.WithWebSocket(ws => ws.WithMessage("Connected"))
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 3: Real-World Scenarios
|
||||
|
||||
### Scenario 1: Real-time Chat Server
|
||||
|
||||
```csharp
|
||||
// Simulate multiple users joining a chat room
|
||||
server.Given(Request.Create()
|
||||
.WithWebSocketPath("/chat")
|
||||
.WithHeader("X-Room-Id", "room123"))
|
||||
.InScenario("ChatRoom")
|
||||
.RespondWith(Response.Create()
|
||||
.WithWebSocket(ws => ws
|
||||
.WithJsonMessage(
|
||||
new { type = "user-joined", username = "{{request.headers.X-Username}}" },
|
||||
delayMs: 0)
|
||||
.WithJsonMessage(
|
||||
new { type = "message", from = "System", text = "Welcome to room123" },
|
||||
delayMs: 500)
|
||||
.WithJsonMessage(
|
||||
new { type = "users-online", count = 3 },
|
||||
delayMs: 1000)
|
||||
.WithTransformer()
|
||||
)
|
||||
.WithWebhook(new Webhook // Audit log
|
||||
{
|
||||
Request = new WebhookRequest
|
||||
{
|
||||
Url = "http://audit/log",
|
||||
Method = "post",
|
||||
BodyData = new BodyData
|
||||
{
|
||||
BodyAsString = "User {{request.headers.X-Username}} joined {{request.headers.X-Room-Id}}"
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Handle user messages (dynamic, simulates echo)
|
||||
server.Given(Request.Create().WithWebSocketPath("/chat"))
|
||||
.RespondWith(Response.Create()
|
||||
.WithWebSocketCallback(async request =>
|
||||
{
|
||||
var username = request.Headers["X-Username"]?.FirstOrDefault() ?? "Anonymous";
|
||||
var messageBody = request.Body ?? "";
|
||||
|
||||
return new[]
|
||||
{
|
||||
new WebSocketMessage
|
||||
{
|
||||
BodyAsString = JsonConvert.SerializeObject(new
|
||||
{
|
||||
type = "message-received",
|
||||
from = username,
|
||||
text = messageBody,
|
||||
timestamp = DateTime.UtcNow
|
||||
}),
|
||||
DelayMs = 100
|
||||
},
|
||||
new WebSocketMessage
|
||||
{
|
||||
BodyAsString = JsonConvert.SerializeObject(new
|
||||
{
|
||||
type = "acknowledgment",
|
||||
status = "delivered"
|
||||
}),
|
||||
DelayMs = 200
|
||||
}
|
||||
};
|
||||
})
|
||||
.WithWebSocketTransformer()
|
||||
);
|
||||
```
|
||||
|
||||
### Scenario 2: Real-time Data Streaming
|
||||
|
||||
```csharp
|
||||
// Stream stock market data
|
||||
server.Given(Request.Create()
|
||||
.WithWebSocketPath("/market-data")
|
||||
.WithWebSocketSubprotocol("market.v1"))
|
||||
.WithTitle("Market Data Stream")
|
||||
.WithDescription("Real-time stock market prices")
|
||||
.RespondWith(Response.Create()
|
||||
.WithWebSocketSubprotocol("market.v1")
|
||||
.WithWebSocketCallback(async request =>
|
||||
{
|
||||
var ticker = request.Headers.ContainsKey("X-Ticker")
|
||||
? request.Headers["X-Ticker"].First()
|
||||
: "AAPL";
|
||||
|
||||
var messages = new List<WebSocketMessage>();
|
||||
|
||||
// Initial subscription confirmation
|
||||
messages.Add(new WebSocketMessage
|
||||
{
|
||||
BodyAsString = JsonConvert.SerializeObject(new
|
||||
{
|
||||
type = "subscribed",
|
||||
ticker = ticker,
|
||||
timestamp = DateTime.UtcNow
|
||||
}),
|
||||
DelayMs = 0
|
||||
});
|
||||
|
||||
// Simulate price updates
|
||||
var random = new Random();
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
var price = 150.00m + (decimal)random.NextDouble() * 10;
|
||||
messages.Add(new WebSocketMessage
|
||||
{
|
||||
BodyAsString = JsonConvert.SerializeObject(new
|
||||
{
|
||||
type = "price-update",
|
||||
ticker = ticker,
|
||||
price = price,
|
||||
timestamp = DateTime.UtcNow
|
||||
}),
|
||||
DelayMs = (i + 1) * 1000 // 1 second between updates
|
||||
});
|
||||
}
|
||||
|
||||
// Final close message
|
||||
messages.Add(new WebSocketMessage
|
||||
{
|
||||
BodyAsString = JsonConvert.SerializeObject(new
|
||||
{
|
||||
type = "stream-end",
|
||||
reason = "Demo ended"
|
||||
}),
|
||||
DelayMs = 6000
|
||||
});
|
||||
|
||||
return messages;
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
### Scenario 3: Server Push Notifications
|
||||
|
||||
```csharp
|
||||
// Long-lived connection for push notifications
|
||||
server.Given(Request.Create()
|
||||
.WithWebSocketPath("/push")
|
||||
.WithHeader("Authorization", new WildcardMatcher("Bearer *")))
|
||||
.AtPriority(1) // Higher priority
|
||||
.RespondWith(Response.Create()
|
||||
.WithWebSocket(ws => ws
|
||||
.WithJsonMessage(new
|
||||
{
|
||||
type = "authenticated",
|
||||
user = "{{request.headers.Authorization}}",
|
||||
connectedAt = "{{now}}"
|
||||
}, delayMs: 0)
|
||||
.WithJsonMessage(new
|
||||
{
|
||||
type = "notification",
|
||||
title = "System Update",
|
||||
message = "A new update is available"
|
||||
}, delayMs: 3000)
|
||||
.WithJsonMessage(new
|
||||
{
|
||||
type = "notification",
|
||||
title = "New Message",
|
||||
message = "You have a new message from admin"
|
||||
}, delayMs: 6000)
|
||||
.WithTransformer()
|
||||
.WithClose(1000, "Connection closed by server")
|
||||
)
|
||||
.WithWebSocketAutoClose(30000) // Auto-close after 30 seconds if idle
|
||||
);
|
||||
```
|
||||
|
||||
### Scenario 4: GraphQL Subscription Simulation
|
||||
|
||||
```csharp
|
||||
// Simulate GraphQL subscription (persistent query updates)
|
||||
server.Given(Request.Create()
|
||||
.WithWebSocketPath("/graphql")
|
||||
.WithWebSocketSubprotocol("graphql-ws")
|
||||
.WithHeader("Content-Type", "application/json"))
|
||||
.RespondWith(Response.Create()
|
||||
.WithWebSocketSubprotocol("graphql-ws")
|
||||
.WithWebSocketCallback(async request =>
|
||||
{
|
||||
var messages = new List<WebSocketMessage>();
|
||||
|
||||
// Parse subscription query from request
|
||||
var query = request.Body ?? "{}";
|
||||
|
||||
// Connection ACK
|
||||
messages.Add(new WebSocketMessage
|
||||
{
|
||||
BodyAsString = JsonConvert.SerializeObject(new
|
||||
{
|
||||
type = "connection_ack"
|
||||
}),
|
||||
DelayMs = 0,
|
||||
IsText = true
|
||||
});
|
||||
|
||||
// Data messages
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
messages.Add(new WebSocketMessage
|
||||
{
|
||||
BodyAsString = JsonConvert.SerializeObject(new
|
||||
{
|
||||
type = "data",
|
||||
id = "1",
|
||||
payload = new
|
||||
{
|
||||
data = new
|
||||
{
|
||||
userNotifications = new[] { new { id = i, message = $"Update {i}" } }
|
||||
}
|
||||
}
|
||||
}),
|
||||
DelayMs = (i + 1) * 2000,
|
||||
IsText = true
|
||||
});
|
||||
}
|
||||
|
||||
// Complete
|
||||
messages.Add(new WebSocketMessage
|
||||
{
|
||||
BodyAsString = JsonConvert.SerializeObject(new
|
||||
{
|
||||
type = "complete",
|
||||
id = "1"
|
||||
}),
|
||||
DelayMs = 6000,
|
||||
IsText = true
|
||||
});
|
||||
|
||||
return messages;
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 4: Best Practices
|
||||
|
||||
### ✅ DO: Follow Request Matching Patterns
|
||||
|
||||
```csharp
|
||||
// Good: Follows established request builder pattern
|
||||
server.Given(Request.Create()
|
||||
.WithWebSocketPath("/api/notifications")
|
||||
.WithWebSocketSubprotocol("notifications")
|
||||
.WithHeader("Authorization", "Bearer *")
|
||||
)
|
||||
```
|
||||
|
||||
### ❌ DON'T: Overload builders with raw configuration
|
||||
|
||||
```csharp
|
||||
// Bad: Breaks fluent pattern
|
||||
var req = new Request(...);
|
||||
req.webSocketSettings = new { ... };
|
||||
```
|
||||
|
||||
### ✅ DO: Use callbacks for dynamic behavior
|
||||
|
||||
```csharp
|
||||
// Good: Dynamic based on request context
|
||||
.WithWebSocketCallback(async request =>
|
||||
{
|
||||
var userId = request.Headers["X-User-Id"].First();
|
||||
return GetMessagesForUser(userId);
|
||||
})
|
||||
```
|
||||
|
||||
### ❌ DON'T: Mix static and dynamic in same mapping
|
||||
|
||||
```csharp
|
||||
// Bad: Confusing multiple patterns
|
||||
.WithWebSocket(ws => ws.WithMessage("Static"))
|
||||
.WithWebSocketCallback(async r => new[] { ... }) // Which wins?
|
||||
```
|
||||
|
||||
### ✅ DO: Use transformers for templating
|
||||
|
||||
```csharp
|
||||
// Good: Dynamic values via templates
|
||||
.WithJsonMessage(new
|
||||
{
|
||||
userId = "{{request.headers.X-User-Id}}"
|
||||
})
|
||||
.WithTransformer()
|
||||
```
|
||||
|
||||
### ❌ DON'T: Hardcode request values
|
||||
|
||||
```csharp
|
||||
// Bad: Doesn't adapt to different requests
|
||||
.WithJsonMessage(new { userId = "hardcoded-user-123" })
|
||||
```
|
||||
|
||||
### ✅ DO: Set appropriate delays for realistic simulation
|
||||
|
||||
```csharp
|
||||
// Good: Simulates realistic network latency
|
||||
.WithJsonMessage(msg1, delayMs: 0) // Immediate
|
||||
.WithJsonMessage(msg2, delayMs: 500) // 500ms later
|
||||
.WithJsonMessage(msg3, delayMs: 2000) // 2 seconds later
|
||||
```
|
||||
|
||||
### ❌ DON'T: Use excessively long delays
|
||||
|
||||
```csharp
|
||||
// Bad: Test hangs unnecessarily
|
||||
.WithJsonMessage(msg, delayMs: 60000) // 1 minute?
|
||||
```
|
||||
|
||||
### ✅ DO: Use subprotocol negotiation for versioning
|
||||
|
||||
```csharp
|
||||
// Good: Version the API
|
||||
.WithWebSocketPath("/api")
|
||||
.WithWebSocketSubprotocol("api.v2")
|
||||
```
|
||||
|
||||
### ❌ DON'T: Embed version in path alone
|
||||
|
||||
```csharp
|
||||
// Bad: Less testable for version negotiation
|
||||
.WithWebSocketPath("/api/v2")
|
||||
```
|
||||
|
||||
### ✅ DO: Chain metadata methods logically
|
||||
|
||||
```csharp
|
||||
// Good: Clear order (matching → metadata → response)
|
||||
server.Given(Request.Create().WithWebSocketPath("/api"))
|
||||
.AtPriority(1)
|
||||
.WithTitle("WebSocket API")
|
||||
.InScenario("ActiveConnections")
|
||||
.WithWebhook(...)
|
||||
.RespondWith(Response.Create()...);
|
||||
```
|
||||
|
||||
### ✅ DO: Test both happy path and error scenarios
|
||||
|
||||
```csharp
|
||||
// Connection accepted
|
||||
server.Given(Request.Create().WithWebSocketPath("/api").WithHeader("Auth", "*"))
|
||||
.RespondWith(Response.Create().WithWebSocket(...));
|
||||
|
||||
// Connection rejected
|
||||
server.Given(Request.Create().WithWebSocketPath("/api").WithHeader("Auth", "invalid"))
|
||||
.RespondWith(Response.Create().WithStatusCode(401));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 5: Fluent Chain Examples
|
||||
|
||||
### Example 1: Minimal Setup
|
||||
|
||||
```csharp
|
||||
server.Given(Request.Create()
|
||||
.WithWebSocketPath("/ws"))
|
||||
.RespondWith(Response.Create()
|
||||
.WithWebSocketMessage("Connected")
|
||||
);
|
||||
```
|
||||
|
||||
### Example 2: Full-Featured Setup
|
||||
|
||||
```csharp
|
||||
server.Given(Request.Create()
|
||||
.WithWebSocketPath("/api/events")
|
||||
.WithWebSocketSubprotocol("events.v1")
|
||||
.WithHeader("Authorization", "Bearer *")
|
||||
.WithHeader("X-Client-Id", "*")
|
||||
)
|
||||
.AtPriority(10)
|
||||
.WithTitle("Event Stream API")
|
||||
.WithDescription("Real-time event streaming for client ID")
|
||||
.InScenario("EventStreaming")
|
||||
.WhenStateIs("Connected")
|
||||
.WillSetStateTo("StreamActive")
|
||||
.WithWebhook(new Webhook
|
||||
{
|
||||
Request = new WebhookRequest
|
||||
{
|
||||
Url = "http://audit/connections",
|
||||
Method = "post",
|
||||
BodyData = new BodyData
|
||||
{
|
||||
BodyAsString = "Client {{request.headers.X-Client-Id}} connected"
|
||||
}
|
||||
}
|
||||
})
|
||||
.RespondWith(Response.Create()
|
||||
.WithWebSocketSubprotocol("events.v1")
|
||||
.WithWebSocket(ws => ws
|
||||
.WithJsonMessage(new
|
||||
{
|
||||
type = "connected",
|
||||
clientId = "{{request.headers.X-Client-Id}}",
|
||||
timestamp = "{{now}}"
|
||||
}, delayMs: 0)
|
||||
.WithJsonMessage(new
|
||||
{
|
||||
type = "status",
|
||||
status = "ready"
|
||||
}, delayMs: 100)
|
||||
.WithTransformer()
|
||||
.WithClose(1000, "Graceful shutdown")
|
||||
)
|
||||
.WithWebSocketAutoClose(300000) // 5 minute timeout
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
The WebSocket fluent interface design:
|
||||
|
||||
1. **Extends, not replaces** existing request/response builders
|
||||
2. **Follows established patterns** (partial classes, method chaining)
|
||||
3. **Enables composition** (messages, transformers, callbacks)
|
||||
4. **Maintains readability** (clear fluent chains)
|
||||
5. **Supports testing** (realistic delays, state, scenarios)
|
||||
6. **Integrates seamlessly** (webhooks, priority, metadata)
|
||||
|
||||
This ensures developers have a consistent, intuitive API for mocking WebSocket behavior.
|
||||
Reference in New Issue
Block a user