SiamCafe · Blog
C# Blazor กับ Load Testing Strategy — วิธีทดสอบ
บทความ

C# Blazor กับ Load Testing Strategy — วิธีทดสอบ

เผยแพร่ 28 พฤษภาคม 2569

Blazor Load Testing

Blazor Server ใช้ SignalR WebSocket สำหรับ Real-time UI Updates ต้องทดสอบทั้ง WebSocket Connections และ HTTP API Blazor WASM ทดสอบ Backend API และ Initial Load Time

Load Testing Strategy สำหรับ Blazor ครอบคลุม SignalR Connections, API Endpoints, Browser Rendering และ Resource Usage วัดผลด้วย Metrics ที่เหมาะสม

NBomber SignalR Load Test

// === NBomber SignalR Load Test สำหรับ Blazor Server ===
// dotnet add package NBomber
// dotnet add package NBomber.Http
// dotnet add package Microsoft.AspNetCore.SignalR.Client

using NBomber.CSharp;
using NBomber.Contracts;
using Microsoft.AspNetCore.SignalR.Client;
using System.Diagnostics;

// === 1. SignalR Connection Test ===
var signalrScenario = Scenario.Create("signalr_connections", async context =>
{
    var connection = new HubConnectionBuilder()
        .WithUrl("https://localhost:5001/_blazor")
        .WithAutomaticReconnect()
        .Build();

    var sw = Stopwatch.StartNew();

    try
    {
        await connection.StartAsync();
        sw.Stop();

        // จำลองการใช้งาน UI
        await Task.Delay(Random.Shared.Next(1000, 5000));

        // ส่ง Event (เหมือน User คลิก)
        // await connection.InvokeAsync("DispatchBrowserEvent", "click", "{}");

        await connection.StopAsync();
        await connection.DisposeAsync();

        return Response.Ok(statusCode: "200",
            sizeBytes: 0,
            latencyMs: sw.ElapsedMilliseconds);
    }
    catch (Exception ex)
    {
        sw.Stop();
        return Response.Fail(statusCode: "500",
            message: ex.Message,
            latencyMs: sw.ElapsedMilliseconds);
    }
})
.WithWarmUpDuration(TimeSpan.FromSeconds(10))
.WithLoadSimulations(
    // Ramp up to 100 concurrent connections
    Simulation.RampingInject(rate: 10, interval: TimeSpan.FromSeconds(1),
        during: TimeSpan.FromMinutes(1)),
    // Sustain 100 connections for 5 minutes
    Simulation.Inject(rate: 100, interval: TimeSpan.FromSeconds(1),
        during: TimeSpan.FromMinutes(5)),
    // Ramp down
    Simulation.RampingInject(rate: 10, interval: TimeSpan.FromSeconds(1),
        during: TimeSpan.FromSeconds(30))
);

// === 2. API Load Test ===
var apiScenario = Scenario.Create("api_load", async context =>
{
    using var client = new HttpClient();
    var sw = Stopwatch.StartNew();

    try
    {
        var response = await client.GetAsync("https://localhost:5001/api/users");
        sw.Stop();

        return response.IsSuccessStatusCode
            ? Response.Ok(statusCode: ((int)response.StatusCode).ToString(),
                sizeBytes: (int)(response.Content.Headers.ContentLength ?? 0),
                latencyMs: sw.ElapsedMilliseconds)
            : Response.Fail(statusCode: ((int)response.StatusCode).ToString(),
                latencyMs: sw.ElapsedMilliseconds);
    }
    catch (Exception ex)
    {
        sw.Stop();
        return Response.Fail(message: ex.Message, latencyMs: sw.ElapsedMilliseconds);
    }
})
.WithLoadSimulations(
    Simulation.Inject(rate: 50, interval: TimeSpan.FromSeconds(1),
        during: TimeSpan.FromMinutes(3))
);

// รัน
NBomberRunner
    .RegisterScenarios(signalrScenario, apiScenario)
    .WithReportFormats(ReportFormat.Html, ReportFormat.Csv)
    .Run();

// dotnet run

k6 API Load Test

// === k6 Load Test สำหรับ Blazor API ===
// k6 run blazor-load-test.js

import http from 'k6/http';
import { check, sleep, group } from 'k6';
import { Rate, Trend } from 'k6/metrics';
import ws from 'k6/ws';

const errorRate = new Rate('error_rate');
const apiLatency = new Trend('api_latency');

export const options = {
  scenarios: {
    // API Load Test
    api_test: {
      executor: 'ramping-vus',
      startVUs: 0,
      stages: [
        { duration: '1m', target: 50 },
        { duration: '3m', target: 200 },
        { duration: '1m', target: 0 },
      ],
    },
    // WebSocket Stress Test
    ws_test: {
      executor: 'ramping-vus',
      startVUs: 0,
      stages: [
        { duration: '1m', target: 100 },
        { duration: '5m', target: 500 },
        { duration: '1m', target: 0 },
      ],
      startTime: '5m',
    },
  },
  thresholds: {
    http_req_duration: ['p(95)<500'],
    error_rate: ['rate<0.05'],
    http_req_failed: ['rate<0.01'],
  },
};

const BASE_URL = __ENV.API_URL || 'https://localhost:5001';

export default function () {
  group('Blazor API Endpoints', () => {
    // GET Users List
    const usersRes = http.get(`/api/users`, {
      headers: { 'Accept': 'application/json' },
    });
    check(usersRes, {
      'users status 200': (r) => r.status === 200,
      'users response < 500ms': (r) => r.timings.duration < 500,
    });
    errorRate.add(usersRes.status !== 200);
    apiLatency.add(usersRes.timings.duration);

    // GET Single User
    const userId = Math.floor(Math.random() * 100) + 1;
    const userRes = http.get(`/api/users/`);
    check(userRes, {
      'user status 200': (r) => r.status === 200,
    });

    // POST Create Order
    const orderPayload = JSON.stringify({
      customerId: userId,
      items: [{ productId: 1, quantity: 2 }],
    });
    const orderRes = http.post(`/api/orders`, orderPayload, {
      headers: { 'Content-Type': 'application/json' },
    });
    check(orderRes, {
      'order created': (r) => r.status === 201 || r.status === 200,
    });
  });

  sleep(1);
}

// k6 run --vus 100 --duration 5m blazor-load-test.js
// k6 run -e API_URL=https://staging.example.com blazor-load-test.js

Performance Optimization

// === Blazor Performance Optimization ===

// 1. Virtualize Component สำหรับ Large Lists
// @page "/large-list"
//
// <Virtualize Items="items" Context="item" ItemSize="50">
//     <ItemContent>
//         <div class="item-row">
//             <span>@item.Name</span>
//             <span>@item.Value</span>
//         </div>
//     </ItemContent>
//     <Placeholder>
//         <div class="placeholder-row">Loading...</div>
//     </Placeholder>
// </Virtualize>

// 2. ShouldRender Override
// @code {
//     private string _lastValue;
//
//     protected override bool ShouldRender()
//     {
//         if (CurrentValue == _lastValue) return false;
//         _lastValue = CurrentValue;
//         return true;
//     }
// }

// 3. IMemoryCache สำหรับ Server
// services.AddMemoryCache();
//
// public class UserService
// {
//     private readonly IMemoryCache _cache;
//
//     public async Task<List<User>> GetUsersAsync()
//     {
//         return await _cache.GetOrCreateAsync("users", async entry =>
//         {
//             entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
//             return await _dbContext.Users.ToListAsync();
//         });
//     }
// }

// 4. Lazy Assembly Loading (WASM)
// <Router AppAssembly="typeof(App).Assembly"
//     AdditionalAssemblies="lazyAssemblies"
//     OnNavigateAsync="OnNavigateAsync">
// </Router>
//
// @code {
//     private List<Assembly> lazyAssemblies = new();
//
//     private async Task OnNavigateAsync(NavigationContext args)
//     {
//         if (args.Path == "admin")
//         {
//             var assemblies = await LazyAssemblyLoader
//                 .LoadAssembliesAsync(new[] { "AdminModule.wasm" });
//             lazyAssemblies.AddRange(assemblies);
//         }
//     }
// }

// 5. Performance Monitoring Middleware
// app.Use(async (context, next) =>
// {
//     var sw = Stopwatch.StartNew();
//     context.Response.OnStarting(() =>
//     {
//         sw.Stop();
//         context.Response.Headers["X-Response-Time"] = $"{sw.ElapsedMilliseconds}ms";
//         return Task.CompletedTask;
//     });
//     await next();
// });

// Performance Checklist
var checklist = new Dictionary<string, bool>
{
    ["Virtualize large lists"] = true,
    ["ShouldRender optimization"] = true,
    ["IMemoryCache for DB queries"] = true,
    ["Lazy assembly loading (WASM)"] = true,
    ["Pre-rendering enabled"] = true,
    ["Response compression"] = true,
    ["SignalR message size limit"] = true,
    ["CDN for static files"] = true,
};

Console.WriteLine("Performance Checklist:");
foreach (var (item, done) in checklist)
{
    var status = done ? "OK" : "TODO";
    Console.WriteLine($"  [{status}] {item}");
}

Best Practices

  • SignalR Limits: ทดสอบ Max Concurrent Connections ก่อน Go-live ตั้ง Connection Limits
  • Virtualize: ใช้ Virtualize Component สำหรับ List ที่มีมากกว่า 100 Items
  • Caching: ใช้ IMemoryCache ลด Database Queries สำหรับข้อมูลที่ไม่เปลี่ยนบ่อย
  • Lazy Loading: ใช้ Lazy Assembly Loading สำหรับ WASM ลด Initial Bundle Size
  • Pre-rendering: เปิด Pre-rendering สำหรับ SEO และ First Contentful Paint
  • Compression: เปิด Response Compression สำหรับ SignalR Messages

Blazor Load Testing ทำอย่างไร

Blazor Server ทดสอบ SignalR WebSocket และ HTTP ใช้ NBomber SignalR k6 API Playwright Browser วัด Response Time Concurrent Connections Memory CPU